Compare commits

...
Author SHA1 Message Date
vimtor d77abb2eca fix(tui): preserve btw responses in forks 2026-09-21 16:51:38 +00:00
vimtor c72037c7c2 fix(tui): use standard fork session copy 2026-09-21 16:36:32 +00:00
vimtor ad5a7f5b12 feat(tui): continue btw answers in a fork 2026-09-21 16:30:21 +00:00
17 changed files with 321 additions and 28 deletions
+28 -2
View File
@@ -222,7 +222,19 @@ export type SessionRemoveInput = { readonly sessionID: Session.ID }
export type SessionRemoveOutput = void
export type SessionRemoveOperation<E = never> = (input: SessionRemoveInput) => Effect.Effect<SessionRemoveOutput, E>
export type SessionForkInput = { readonly sessionID: Session.ID; readonly before?: SessionMessage.ID | undefined }
export type SessionForkInput = {
readonly sessionID: Session.ID
readonly before?: SessionMessage.ID | undefined
readonly continuation?:
| {
readonly prompt: string
readonly response: string
readonly agent: Agent.ID
readonly model: Model.Ref
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
}
| undefined
}
export type SessionForkOutput = Session.Info
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
@@ -402,7 +414,12 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
export type SessionGenerateOutput = { readonly text: string }
export type SessionGenerateOutput = {
readonly text: string
readonly agent: Agent.ID
readonly model: Model.Ref
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
}
export type SessionGenerateOperation<E = never> = (
input: SessionGenerateInput,
) => Effect.Effect<SessionGenerateOutput, E>
@@ -574,6 +591,15 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly boundary: Session.ForkBoundary
readonly continuation?:
| {
readonly prompt: string
readonly response: string
readonly agent: Agent.ID
readonly model: Model.Ref
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
}
| undefined
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
@@ -429,7 +429,10 @@ const EndpointSessionRemove = (raw: RawClient["server.session"]) => (input: Sess
const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: SessionForkInput) =>
preserveEffect<SessionForkOutput>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { before: input["before"] } }).pipe(
raw["session.fork"]({
params: { sessionID: input["sessionID"] },
payload: { before: input["before"], continuation: input["continuation"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -626,7 +626,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
body: { before: input["before"] },
body: { before: input["before"], continuation: input["continuation"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
+38 -3
View File
@@ -169,8 +169,6 @@ export type SessionInboxCompactionPayload = {}
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type LocationRef = { directory: string; workspaceID?: string }
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
@@ -496,6 +494,23 @@ export type SessionMessageModelSelected = {
previous?: ModelRef
}
export type SessionGenerateResponse = {
data: {
text: string
agent: string
model: ModelRef
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
}
}
export type SessionForkContinuation = {
prompt: string
response: string
agent: string
model: ModelRef
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
}
export type PromptFileAttachment = {
data: PromptBase64
mime: string
@@ -1801,6 +1816,7 @@ export type SessionForked = {
sessionID: string
parentID: string
boundary: SessionForkBoundary
continuation?: SessionForkContinuation
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
@@ -3921,7 +3937,26 @@ export type SessionRemoveOutput = void
export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly before?: { readonly before?: string | undefined }["before"]
readonly before?: {
readonly before?: string | null
readonly continuation?: {
readonly prompt: string
readonly response: string
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
} | null
}["before"]
readonly continuation?: {
readonly before?: string | null
readonly continuation?: {
readonly prompt: string
readonly response: string
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
} | null
}["continuation"]
}
export type SessionForkOutput = { data: SessionInfo }["data"]
+47
View File
@@ -927,6 +927,53 @@ test("session methods use the public HTTP contract", async () => {
})
})
test("session fork carries a generated continuation", async () => {
const bodies: unknown[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
if (typeof init?.body === "string") bodies.push(JSON.parse(init.body))
if (url.endsWith("/generate")) {
return Response.json({
data: {
text: "Side answer",
agent: "build",
model: { id: "claude", providerID: "anthropic" },
finish: "stop",
},
})
}
return Response.json(session)
},
})
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Side question" })
await client.session.fork({
sessionID: "ses_test",
continuation: {
prompt: "Side question",
response: generated.text,
agent: generated.agent,
model: generated.model,
finish: generated.finish,
},
})
expect(bodies).toEqual([
{ prompt: "Side question" },
{
continuation: {
prompt: "Side question",
response: "Side answer",
agent: "build",
model: { id: "claude", providerID: "anthropic" },
finish: "stop",
},
},
])
})
test("middleware errors remain declared client errors", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
+1 -1
View File
@@ -532,7 +532,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
switchAgent: sessions.switchAgent,
switchModel: sessions.switchModel,
prompt: sessions.prompt,
generate: (input) => sessions.generate(input).pipe(Effect.map((text) => ({ text }))),
generate: sessions.generate,
command: (input) => sessions.command({ ...input, command: input.name }),
update: Effect.fn(function* (input) {
yield* sessions.get(input.sessionID)
+4 -1
View File
@@ -4,6 +4,7 @@ export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode/ai"
import { ListAnchor } from "@opencode/schema/session"
import type { SessionFork } from "@opencode/schema/session-fork"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode/schema/model"
@@ -95,6 +96,7 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
before?: SessionMessage.ID
continuation?: SessionFork.Continuation
}
export {
@@ -182,7 +184,7 @@ export interface Interface {
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
}) => Effect.Effect<SessionGenerate.Result, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
@@ -337,6 +339,7 @@ const layer = Layer.effect(
sessionID,
parentID: parent.id,
boundary: { type: input.before ? "before" : "through", messageID: boundary.id },
continuation: input.continuation,
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
+16 -1
View File
@@ -1,6 +1,9 @@
export * as SessionGenerate from "./generate.js"
import { LLMClient, Message, type AIError } from "@opencode/ai"
import type { Agent } from "@opencode/schema/agent"
import type { FinishReason } from "@opencode/schema/llm"
import type { Model } from "@opencode/schema/model"
import { Effect } from "effect"
import { Database } from "../database/database.js"
import { Instance } from "../instance/service.js"
@@ -16,6 +19,13 @@ import type { SessionSchema } from "./schema.js"
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | AIError
export interface Result {
readonly text: string
readonly agent: Agent.ID
readonly model: Model.Ref
readonly finish: FinishReason
}
/** Generates text from current Session context without mutating the Session. */
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
session: SessionSchema.Info
@@ -62,6 +72,11 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
})
const response = yield* llm.generate(prepared.request, prepared.options)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
return {
text: response.text,
agent: selection.agent.id,
model: model.ref,
finish: response.finishReason.normalized,
}
}).pipe(instances.provide(input.session))
})
+50 -1
View File
@@ -55,6 +55,33 @@ const forkTitle = (value?: string) => {
return `${value} (fork #1)`
}
function forkContinuation(event: typeof SessionEvent.Forked.Type) {
const continuation = event.data.continuation
if (!continuation) return []
const id = SessionMessage.ID.fromEvent(event.id)
const created = DateTime.makeUnsafe(event.created)
return [
SessionMessage.User.make({
id: SessionMessage.ID.make(`${id}_user`),
type: "user",
text: continuation.prompt,
files: [],
agents: [],
skills: [],
time: { created },
}),
SessionMessage.Assistant.make({
id: SessionMessage.ID.make(`${id}_assistant`),
type: "assistant",
agent: continuation.agent,
model: continuation.model,
content: [SessionMessage.AssistantText.make({ type: "text", text: continuation.response })],
finish: continuation.finish,
time: { created, completed: created },
}),
]
}
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
return db
.update(SessionTable)
@@ -220,7 +247,29 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
cursor = rows.at(-1)!.seq
}
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
const continuation = forkContinuation(event)
const lastSeq = (copiedSeq ?? 0) + continuation.length
if (continuation.length > 0) {
yield* db
.insert(SessionMessageTable)
.values(
continuation.map((message, index) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return {
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
seq: (copiedSeq ?? 0) + index + 1,
time_created: event.created,
data,
}
}),
)
.run()
.pipe(Effect.orDie)
}
if (lastSeq > 0) yield* Bus.reserveSequence(db, event.data.sessionID, lastSeq)
if (event.data.instructions)
yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions)
})
+6 -1
View File
@@ -327,7 +327,12 @@ it.effect(
Effect.provideService(Instance.Service, instances),
)
expect(result).toBe("Transient answer")
expect(result).toEqual({
text: "Transient answer",
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
finish: "stop",
})
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
+37
View File
@@ -1620,6 +1620,43 @@ describe("SessionRunnerLLM", () => {
).toMatchObject({ current_values: { "test/context": Instructions.hash("Latest context") } })
})
scenario("appends a generated continuation exchange to a fork", function* (s) {
yield* s.runPrompt("Main question")
const forked = yield* s.session.fork({
sessionID,
continuation: {
prompt: "Side question",
response: "Side answer",
agent: Agent.ID.make("build"),
model: Model.Ref.make({
id: Model.ID.make(s.currentModel.id),
providerID: Provider.ID.make(s.currentModel.provider),
}),
finish: "stop",
},
})
const messages = yield* s.session.messages({ sessionID: forked.id, order: "asc" })
expect(messages.at(-2)).toMatchObject({ type: "user", text: "Side question" })
expect(messages.at(-1)).toMatchObject({
type: "assistant",
agent: "build",
content: [{ type: "text", text: "Side answer" }],
finish: "stop",
})
yield* s.session.prompt({ sessionID: forked.id, text: "Continue", resume: false })
yield* s.session.resume(forked.id)
expect(
s.requests
.at(-1)
?.messages.filter((message) => message.role === "assistant")
.flatMap((message) => message.content)
.filter((part) => part.type === "text")
.map((part) => part.text),
).toContain("Side answer")
})
scenario("keeps nested forks self-contained", function* (s) {
yield* s.runPrompt("First")
s.systemBaseline = "Changed context"
+15 -4
View File
@@ -3,6 +3,7 @@ import { SessionInbox } from "@opencode/schema/session-inbox"
import { PromptInput } from "@opencode/schema/prompt-input"
import { Session } from "@opencode/schema/session"
import { SessionStats } from "@opencode/schema/session-stats"
import { SessionFork } from "@opencode/schema/session-fork"
import { InstructionEntry } from "@opencode/schema/instruction-entry"
import { Project } from "@opencode/schema/project"
import {
@@ -35,6 +36,7 @@ import {
import { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { FinishReason } from "@opencode/schema/llm"
import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
@@ -308,7 +310,10 @@ export const makeSessionGroup = <
.add(
HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ before: SessionMessage.ID.pipe(Schema.optional) }),
payload: Schema.Struct({
before: SessionMessage.ID.pipe(Schema.optional),
continuation: SessionFork.Continuation.pipe(Schema.optional),
}),
success: Schema.Struct({ data: PublicSessionInfo }),
error: [SessionNotFoundError, MessageNotFoundError, InvalidRequestError],
})
@@ -318,7 +323,7 @@ export const makeSessionGroup = <
identifier: "session.fork",
summary: "Fork session",
description:
"Create a child session by copying projected history before a message. Omit before to copy the full history.",
"Create a child session by copying projected history before a message. Omit before to copy the full history; provide a continuation to append a completed user and assistant exchange.",
}),
),
)
@@ -706,7 +711,12 @@ export const makeSessionGroup = <
params: { sessionID: Session.ID },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({
data: Schema.Struct({ text: Schema.String }),
data: Schema.Struct({
text: Schema.String,
agent: Agent.ID,
model: Model.Ref,
finish: FinishReason,
}),
}).annotate({ identifier: "SessionGenerateResponse" }),
error: [SessionNotFoundError, ServiceUnavailableError],
})
@@ -715,7 +725,8 @@ export const makeSessionGroup = <
OpenApi.annotations({
identifier: "session.generate",
summary: "Generate text from session context",
description: "Generate transient text from the current session context without mutating session history.",
description:
"Generate transient text and its resolved agent, model, and finish metadata from the current session context without mutating session history.",
}),
),
)
+1
View File
@@ -187,6 +187,7 @@ export const Forked = Event.durable({
...Base,
parentID: SessionID,
boundary: SessionFork.Boundary,
continuation: SessionFork.Continuation.pipe(optional),
instructions: Instruction.Values.pipe(optional),
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
},
+12
View File
@@ -1,6 +1,9 @@
export * as SessionFork from "./session-fork.js"
import { Schema } from "effect"
import { Agent } from "./agent.js"
import { FinishReason } from "./llm.js"
import { Model } from "./model.js"
import { SessionMessage } from "./session-message.js"
export const Boundary = Schema.Union([
@@ -8,3 +11,12 @@ export const Boundary = Schema.Union([
Schema.Struct({ type: Schema.Literal("through"), messageID: SessionMessage.ID }),
]).annotate({ identifier: "Session.ForkBoundary" })
export type Boundary = typeof Boundary.Type
export interface Continuation extends Schema.Schema.Type<typeof Continuation> {}
export const Continuation = Schema.Struct({
prompt: Schema.String,
response: Schema.String,
agent: Agent.ID,
model: Model.Ref,
finish: FinishReason,
}).annotate({ identifier: "Session.ForkContinuation" })
+15 -9
View File
@@ -223,14 +223,20 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.fork",
Effect.fn(function* (ctx) {
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, before: ctx.payload.before }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
data: yield* session
.fork({
sessionID: ctx.params.sessionID,
before: ctx.payload.before,
continuation: ctx.payload.continuation,
})
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
),
),
),
}
}),
)
@@ -582,7 +588,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.generate",
Effect.fn(function* (ctx) {
const text = yield* session
const result = yield* session
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })
.pipe(
Effect.mapError((error) =>
@@ -591,7 +597,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
: new ServiceUnavailableError({ message: error.message, service: "session generation" }),
),
)
return { data: { text } }
return { data: result }
}),
)
.handle(
@@ -216,7 +216,12 @@ it.live(
for (const config of configs) {
yield* llm.push(TestLLM.text(`generated ${config.id}`, config.tool))
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toBe(`generated ${config.id}`)
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toEqual({
text: `generated ${config.id}`,
agent: Agent.ID.make("build"),
model: model.ref,
finish: "stop",
})
yield* sessions.command({ sessionID: config.id, command: "instance-check", text: "" })
expect(yield* sessions.inbox(config.id)).toMatchObject([
{ type: "user", payload: { text: `command ${config.tool} [${config.tool}]` } },
@@ -69,7 +69,25 @@ export default Plugin.define({
.generate({ sessionID: route.sessionID, prompt: [instructions, question].join("\n\n") })
.then((result) => {
context.ui.dialog.show(() => (
<Answer question={question} answer={result.text.trim()} markdown={plugins.markdown} />
<Answer
question={question}
answer={result.text.trim()}
markdown={plugins.markdown}
onFork={async () => {
const fork = await context.client.session.fork({
sessionID: route.sessionID,
continuation: {
prompt: question,
response: result.text.trim(),
agent: result.agent,
model: result.model,
finish: result.finish,
},
})
context.ui.dialog.clear()
context.ui.router.navigate({ type: "session", sessionID: fork.id })
}}
/>
))
context.ui.dialog.set({ size: "large", centered: true })
})
@@ -89,6 +107,7 @@ export function Answer(props: {
question: string
answer: string
markdown: ReturnType<typeof usePlugin>["markdown"]
onFork: () => Promise<void>
}) {
const dialog = useDialog()
const toast = useToast()
@@ -98,6 +117,7 @@ export function Answer(props: {
const syntax = useThemes().currentSyntax
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [forking, setForking] = createSignal(false)
let scroll: ScrollBoxRenderable | undefined
const copy = () => {
@@ -107,9 +127,21 @@ export function Answer(props: {
.catch(toast.error)
}
const fork = async () => {
if (forking()) return
setForking(true)
await props
.onFork()
.catch(toast.error)
.finally(() => setForking(false))
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "c", title: "Copy answer", group: "Dialog", run: copy }],
commands: [
{ bind: "c", title: "Copy answer", group: "Dialog", run: copy },
{ bind: "f", title: "Fork session", group: "Dialog", run: fork },
],
}))
useKeyboard((event) => {
@@ -166,6 +198,12 @@ export function Answer(props: {
</span>
<span style={{ fg: theme.text.muted }}>{copied() ? "" : " copy"}</span>
</text>
<text onMouseUp={() => void fork()}>
<span style={{ fg: theme.text.base }}>
<b>{forking() ? "…" : "f"}</b>
</span>
<span style={{ fg: theme.text.muted }}>{forking() ? " forking" : " fork session"}</span>
</text>
<text fg={theme.text.muted}>↑/↓ scroll</text>
</box>
</box>