diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5123cea56754..53ea3d6138b0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -677,6 +677,16 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if ( + inputText.startsWith("/") && + iife(() => { + const name = inputText.split("\n")[0].split(" ")[0].slice(1) + const match = command.slashes().find((s) => s.display === "/" + name || s.aliases?.includes("/" + name)) + if (match) match.onSelect() + return !!match + }) + ) { + // slash command dispatched inside the iife above } else if ( inputText.startsWith("/") && iife(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index afeed6a22f03..b3ba904e5042 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -557,6 +557,40 @@ export function Session() { }) }, }, + { + title: "Continue interrupted session", + value: "session.continue", + category: "Session", + slash: { + name: "continue", + }, + onSelect: (dialog) => { + const status = sync.data.session_status?.[route.sessionID] + if (status?.type !== "idle" && status?.type !== undefined) { + toast.show({ message: "Session is busy", variant: "warning", duration: 3000 }) + dialog.clear() + return + } + // Uses raw fetch because the SDK has not been regenerated yet + sdk + .fetch(`${sdk.url}/session/${route.sessionID}/continue`, { + method: "POST", + }) + .then(async (r) => { + if (!r.ok) { + const body = await r.json().catch(() => null) + throw new Error(body?.data?.message ?? `Continue failed: ${r.status}`) + } + }) + .catch((e) => + toast.show({ + message: e instanceof Error ? e.message : "Failed to continue session", + variant: "error", + }), + ) + dialog.clear() + }, + }, { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 278740c57d9c..0c0939816cdb 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -21,7 +21,7 @@ export function errorHandler(log: Log.Logger): ErrorHandler { else status = 500 return c.json(err.toObject(), { status }) } - if (err instanceof Session.BusyError) { + if (err instanceof Session.BusyError || err instanceof Session.NothingToContinueError) { return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) } if (err instanceof HTTPException) return err.getResponse() diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index c33c5e989b37..c9342a4baa0d 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -385,6 +385,34 @@ export const SessionRoutes = lazy(() => return c.json(true) }, ) + .post( + "/:sessionID/continue", + describeRoute({ + summary: "Continue session", + description: "Continue an interrupted session from where it left off without creating a new user message.", + operationId: "session.continue", + responses: { + 200: { + description: "Continued session", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("param", z.object({ sessionID: SessionID.zod })), + async (c) => { + return c.json(await SessionPrompt.continue_(c.req.valid("param").sessionID)) + }, + ) .post( "/:sessionID/share", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96252..66e398159443 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -312,6 +312,12 @@ export namespace Session { } } + export class NothingToContinueError extends Error { + constructor(public readonly sessionID: string) { + super(`Nothing to continue in session ${sessionID}`) + } + } + export interface Interface { readonly create: (input?: { parentID?: SessionID diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 24996c8d4b29..9e42ab38dd1c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -74,6 +74,7 @@ export namespace SessionPrompt { readonly loop: (input: z.infer) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect readonly command: (input: CommandInput) => Effect.Effect + readonly continue: (sessionID: SessionID) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -1323,6 +1324,38 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) + const continue_ = Effect.fn("SessionPrompt.continue")(function* (sessionID: SessionID) { + const s = yield* InstanceState.get(state) + if (s.runners.get(sessionID)?.busy) throw new Session.BusyError(sessionID) + + const last = (yield* MessageV2.filterCompactedEffect(sessionID)).findLast( + (m): m is MessageV2.WithParts & { info: MessageV2.Assistant } => m.info.role === "assistant", + ) + if (!last) throw new Session.NothingToContinueError(sessionID) + + if ( + last.info.finish && + last.info.finish !== "tool-calls" && + !last.parts.some( + (p) => p.type === "tool" && (p.state.status === "pending" || p.state.status === "running"), + ) && + !last.info.error + ) { + return yield* prompt({ + sessionID, + parts: [{ type: "text", text: "continue" }], + }) + } + + last.info.error = undefined + last.info.finish = "tool-calls" + last.info.time.completed ??= Date.now() + yield* sessions.updateMessage(last.info) + + yield* sessions.touch(sessionID) + return yield* getRunner(s.runners, sessionID).ensureRunning(runLoop(sessionID)) + }) + const lastAssistant = (sessionID: SessionID) => Effect.promise(async () => { let latest: MessageV2.WithParts | undefined @@ -1704,6 +1737,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the loop, shell, command, + continue: continue_, resolvePromptParts, }) }), @@ -1818,6 +1852,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID))) } + export async function continue_(sessionID: SessionID) { + return runPromise((svc) => svc.continue(SessionID.zod.parse(sessionID))) + } + export const LoopInput = z.object({ sessionID: SessionID.zod, }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index e6dba676ce35..0f1a4760f946 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { SessionPrompt } from "../../src/session/prompt" @@ -34,6 +35,32 @@ async function user(sessionID: SessionID, text: string) { return msg } +async function assistant(sessionID: SessionID, parentID: string, opts?: Partial) { + const msg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant" as const, + sessionID, + parentID: MessageID.make(parentID), + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelID.make("test"), + providerID: ProviderID.make("test"), + time: { created: Date.now(), completed: Date.now() }, + ...opts, + }) + await Session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: msg.id, + type: "text", + text: "assistant response", + }) + return msg +} + describe("session action routes", () => { test("abort route calls SessionPrompt.cancel", async () => { await using tmp = await tmpdir({ git: true }) @@ -80,4 +107,524 @@ describe("session action routes", () => { }, }) }) + + test("continue route calls SessionPrompt.continue_", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const msg = await user(session.id, "hello") + const result: MessageV2.WithParts = { + info: { + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + parentID: msg.id, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelID.make("test"), + providerID: ProviderID.make("test"), + time: { created: Date.now() }, + finish: "stop", + }, + parts: [], + } + const spy = spyOn(SessionPrompt, "continue_").mockResolvedValue(result) + const app = Server.Default() + + const res = await app.request(`/session/${session.id}/continue`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.info.role).toBe("assistant") + expect(spy).toHaveBeenCalled() + + await Session.remove(session.id) + }, + }) + }) + + test("continue route returns 400 when session is busy", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const spy = spyOn(SessionPrompt, "continue_").mockRejectedValue(new Session.BusyError(session.id)) + const app = Server.Default() + + const res = await app.request(`/session/${session.id}/continue`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + expect(spy).toHaveBeenCalled() + + await Session.remove(session.id) + }, + }) + }) + + test("continue route returns 400 when nothing to continue", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const spy = spyOn(SessionPrompt, "continue_").mockRejectedValue(new Session.NothingToContinueError(session.id)) + const app = Server.Default() + + const res = await app.request(`/session/${session.id}/continue`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + expect(spy).toHaveBeenCalled() + + await Session.remove(session.id) + }, + }) + }) + + test("continue route works without body", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const msg = await user(session.id, "hello") + const result: MessageV2.WithParts = { + info: { + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + parentID: msg.id, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelID.make("test"), + providerID: ProviderID.make("test"), + time: { created: Date.now() }, + finish: "stop", + }, + parts: [], + } + const spy = spyOn(SessionPrompt, "continue_").mockResolvedValue(result) + const app = Server.Default() + + const res = await app.request(`/session/${session.id}/continue`, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(spy).toHaveBeenCalledWith(session.id) + + await Session.remove(session.id) + }, + }) + }) +}) + +describe("continue_ logic", () => { + test("sends new prompt when assistant finished normally", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + await assistant(session.id, usr.id, { finish: "stop" }) + + // The cleanly-finished path calls prompt() which creates a "continue" + // user message then starts a loop. Without a real LLM it throws. + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + // A new user message with text "continue" should have been persisted + const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((m) => m.info.role === "user") + expect(users.length).toBe(2) + + await Session.remove(session.id) + }, + }) + }) + + test("sends new prompt when finish is 'length' and no error", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + await assistant(session.id, usr.id, { finish: "length" }) + + // "length" with no error takes the cleanly-finished path (sends "continue") + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((m) => m.info.role === "user") + expect(users.length).toBe(2) + + await Session.remove(session.id) + }, + }) + }) + + test("throws when no assistant message exists", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await user(session.id, "hello") + + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeInstanceOf(Session.NothingToContinueError) + expect(err.message).toContain("Nothing to continue") + expect(err.sessionID).toBe(session.id) + + await Session.remove(session.id) + }, + }) + }) + + test("throws when session has no messages at all", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeInstanceOf(Session.NothingToContinueError) + expect(err.sessionID).toBe(session.id) + + await Session.remove(session.id) + }, + }) + }) + + test("clears error on aborted assistant and re-enters loop", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + await assistant(session.id, usr.id, { + finish: "stop", + error: { name: "MessageAbortedError", data: { message: "cancelled" } }, + }) + + // continue_ patches the assistant and calls runLoop, + // which will fail without a real LLM + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + // The assistant should be kept but patched + const msgs = await Session.messages({ sessionID: session.id }) + const ast = msgs.findLast((m) => m.info.role === "assistant") + expect(ast).toBeDefined() + if (ast?.info.role === "assistant") { + expect(ast.info.error).toBeUndefined() + expect(ast.info.finish).toBe("tool-calls") + } + + await Session.remove(session.id) + }, + }) + }) + + test("patches interrupted assistant (no finish) and re-enters loop", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + await assistant(session.id, usr.id, { finish: undefined }) + + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + const msgs = await Session.messages({ sessionID: session.id }) + const ast = msgs.findLast((m) => m.info.role === "assistant") + expect(ast).toBeDefined() + if (ast?.info.role === "assistant") { + expect(ast.info.finish).toBe("tool-calls") + expect(ast.info.time.completed).toBeDefined() + } + + await Session.remove(session.id) + }, + }) + }) + + test("continue on interrupted path touches session timestamp", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const before = (await Session.get(session.id)).time.updated + + const usr = await user(session.id, "hello") + await assistant(session.id, usr.id, { finish: undefined }) + + // Small delay so updated timestamp is distinguishable + await new Promise((r) => setTimeout(r, 10)) + + await SessionPrompt.continue_(session.id).catch(() => {}) + + const after = (await Session.get(session.id)).time.updated + expect(after).toBeGreaterThan(before) + + await Session.remove(session.id) + }, + }) + }) + + test("does not early-return when assistant has finish but pending tool parts", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + const ast = await assistant(session.id, usr.id, { finish: "stop" }) + + // Add a tool part to the assistant — simulates providers that return + // "stop" even when tool calls are present. + await Session.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: ast.id, + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "pending", + input: { command: "echo hi" }, + raw: '{"command":"echo hi"}', + }, + }) + + // continue_ should NOT early-return; it should patch the assistant + // and re-enter the loop (which will fail without a real LLM). + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + // The assistant should be kept and patched + const msgs = await Session.messages({ sessionID: session.id }) + const ast2 = msgs.findLast((m) => m.info.role === "assistant") + expect(ast2).toBeDefined() + if (ast2?.info.role === "assistant") { + expect(ast2.info.finish).toBe("tool-calls") + expect(ast2.info.error).toBeUndefined() + } + + await Session.remove(session.id) + }, + }) + }) + + test("patches assistant when finish is 'tool-calls' with no error", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + await assistant(session.id, usr.id, { finish: "tool-calls" }) + + // finish="tool-calls" skips the early-return path and patches the assistant + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + const msgs = await Session.messages({ sessionID: session.id }) + const ast = msgs.findLast((m) => m.info.role === "assistant") + expect(ast).toBeDefined() + if (ast?.info.role === "assistant") { + expect(ast.info.finish).toBe("tool-calls") + expect(ast.info.error).toBeUndefined() + } + + // Should NOT have created a new user message + const users = msgs.filter((m) => m.info.role === "user") + expect(users.length).toBe(1) + + await Session.remove(session.id) + }, + }) + }) + + test("sends new prompt when finish is set and all tool parts are completed", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + const ast = await assistant(session.id, usr.id, { finish: "stop" }) + + // Add a completed tool part — no pending tools remain. + await Session.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: ast.id, + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "echo hi" }, + output: "hi", + title: "bash", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + + // finish="stop" + no pending tools takes the cleanly-finished path, + // which sends a "continue" prompt (requires LLM, so it throws here). + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((m) => m.info.role === "user") + expect(users.length).toBe(2) + + await Session.remove(session.id) + }, + }) + }) + + test("continues based on latest assistant when multiple exist", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + // First assistant finished cleanly + await assistant(session.id, usr.id, { finish: "stop" }) + // Second assistant was interrupted (no finish) + await assistant(session.id, usr.id, { finish: undefined }) + + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + // Should patch the LATEST assistant (the interrupted one) + const msgs = await Session.messages({ sessionID: session.id }) + const assistants = msgs.filter((m) => m.info.role === "assistant") + expect(assistants.length).toBe(2) + const latest = assistants[assistants.length - 1] + if (latest.info.role === "assistant") { + expect(latest.info.finish).toBe("tool-calls") + expect(latest.info.time.completed).toBeDefined() + } + + await Session.remove(session.id) + }, + }) + }) + + test("does not early-return when assistant has running tool parts", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const usr = await user(session.id, "hello") + const ast = await assistant(session.id, usr.id, { finish: "stop" }) + + await Session.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: ast.id, + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "running", + input: { command: "echo hi" }, + raw: '{"command":"echo hi"}', + title: "bash", + metadata: {}, + time: { start: Date.now() }, + }, + }) + + const err = await SessionPrompt.continue_(session.id).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + + const msgs = await Session.messages({ sessionID: session.id }) + const ast2 = msgs.findLast((m) => m.info.role === "assistant") + expect(ast2).toBeDefined() + if (ast2?.info.role === "assistant") { + expect(ast2.info.finish).toBe("tool-calls") + expect(ast2.info.error).toBeUndefined() + } + + await Session.remove(session.id) + }, + }) + }) + + test("NothingToContinueError has correct sessionID property", () => { + const id = "test-session-id" + const err = new Session.NothingToContinueError(id) + expect(err).toBeInstanceOf(Error) + expect(err.sessionID).toBe(id) + expect(err.message).toBe(`Nothing to continue in session ${id}`) + }) })