diff --git a/.specgit.yaml b/.specgit.yaml index 63243e620b..9dbf6d7a77 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,9 +1,7 @@ version: 1 -delivery: release-train-remediation +delivery: headless-init-does context: kind: branch - branch: fix/401-release-train-remediation + branch: fix/404-headless-init-does issues: - - 401 - - 402 -pr: 403 + - 404 diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 1112688901..3c7cb06b0f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -48,11 +48,33 @@ const tryParseJson = (text: string) => catch: () => new HttpApiError.BadRequest({}), }) +// #404: headless clients (`opencode run "/init"`, SDK text prompts) deliver +// slash commands as plain text parts to POST /session/:id/message. The TUI +// routes them client-side before calling /command, but nothing server-side +// did, so SessionPrompt.command never ran and Command.Event.Executed never +// fired (project.time_initialized stayed NULL). Mirror the TUI's parse — +// first line carries /command + args, later lines join the arguments — and +// only ever route single-text-part prompts. +function parseCommandPayload(payload: typeof PromptPayload.Type) { + const part = payload.parts.length === 1 ? payload.parts[0] : undefined + const text = part?.type === "text" && part.text.startsWith("/") ? part.text : undefined + if (!text) return undefined + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [token, ...firstLineArgs] = firstLine.split(" ") + const rest = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return { + command: token.slice(1), + arguments: firstLineArgs.join(" ") + (rest ? "\n" + rest : ""), + } +} + export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service const shareSvc = yield* SessionShare.Service const promptSvc = yield* SessionPrompt.Service + const commands = yield* Command.Service const revertSvc = yield* SessionRevert.Service const compactSvc = yield* SessionCompaction.Service const runState = yield* SessionRunState.Service @@ -314,17 +336,36 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return true }) - const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: { + // Text that names a registered command routes through SessionPrompt.command + // so headless entrypoints get the same Command.Event.Executed lifecycle + // (incl. project init stamping) as the TUI; a registry miss falls through + // to a plain prompt turn, preserving existing text-prompt semantics. + const promptOrCommand = Effect.fn("SessionHttpApi.promptOrCommand")(function* (ctx: { params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - yield* requireSession(ctx.params.sessionID) - const message = yield* promptSvc - .prompt({ - ...ctx.payload, + const parsed = parseCommandPayload(ctx.payload) + const cmd = parsed ? yield* commands.get(parsed.command) : undefined + if (parsed && cmd) { + return yield* promptSvc.command({ sessionID: ctx.params.sessionID, + command: parsed.command, + arguments: parsed.arguments, + messageID: ctx.payload.messageID, + agent: ctx.payload.agent, + variant: ctx.payload.variant, + ...(ctx.payload.model ? { model: `${ctx.payload.model.providerID}/${ctx.payload.model.modelID}` } : {}), }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + } + return yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }) + }) + + const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof PromptPayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + const message = yield* promptOrCommand(ctx).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { contentType: "application/json", }) @@ -335,7 +376,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof PromptPayload.Type }) { yield* requireSession(ctx.params.sessionID) - yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + yield* promptOrCommand(ctx).pipe( Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) diff --git a/packages/opencode/test/cli/run/headless-init.test.ts b/packages/opencode/test/cli/run/headless-init.test.ts new file mode 100644 index 0000000000..3ff394e047 --- /dev/null +++ b/packages/opencode/test/cli/run/headless-init.test.ts @@ -0,0 +1,150 @@ +// Regression test for #404: headless /init never stamped project.time_initialized, +// leaving MEMORY fail-closed inert. Root cause: `opencode run "/init"` and serve +// text prompts deliver "/init" as a plain text part at POST /session/:id/message — +// slash routing existed only in the TUI (client-side), so SessionPrompt.command +// never ran, Command.Event.Executed never fired, and the project init listener in +// src/project/project.ts never stamped the row. +// +// Fix under test: the server-side prompt endpoints route single-text-part +// prompts that name a registered command through SessionPrompt.command. +// +// Harness notes (all arms): +// - The project row only exists for git worktrees — a bare temp dir resolves to +// the global project (src/project/project.ts fromDirectory), so each arm +// git-inits the fixture home first. +// - test/preload.ts sets OPENCODE_DB=":memory:" globally and the CLI fixture +// spawns children with the runner env merged in; overriding OPENCODE_DB to "" +// (falsy → default path) restores the file-backed DB under the isolated home. +// - run.ts resolves its directory from process.env.PWD (leaked from the runner +// by the fixture), so run arms pin PWD to the fixture home. +// - The row's worktree is Filesystem.resolve'd (realpath); on macOS the fixture +// home can still be a symlinked path (/var → /private/var), so both +// spellings are accepted when matching. +import { describe, expect } from "bun:test" +import { Database } from "bun:sqlite" +import { existsSync, realpathSync } from "node:fs" +import path from "node:path" +import { Effect, Schema } from "effect" +import { HttpBody, HttpClient } from "effect/unstable/http" +import { cliIt } from "../../lib/cli-process" +import { pollWithTimeout } from "../../lib/effect" + +const dbPath = (home: string) => path.join(home, ".local/share/opencode/opencode-local.db") + +const ProjectRow = Schema.Struct({ + worktree: Schema.String, + time_initialized: Schema.NullOr(Schema.Number), +}) + +const SessionRef = Schema.Struct({ id: Schema.String }) + +const gitInit = (home: string) => Effect.promise(() => Bun.$`git -C ${home} init -q`.quiet().text()) + +function worktreeCandidates(home: string) { + try { + return [home, realpathSync(home)] + } catch { + return [home] + } +} + +// Returns time_initialized for the fixture worktree, or undefined while the DB +// file / row / stamp is not there yet. +const stampOf = (home: string) => + Effect.sync(() => { + if (!existsSync(dbPath(home))) return undefined + const candidates = worktreeCandidates(home) + const db = new Database(dbPath(home), { readonly: true }) + try { + const rows = Schema.decodeUnknownSync(Schema.Array(ProjectRow))( + db.query("SELECT worktree, time_initialized FROM project").all(), + ) + return rows.find((row) => candidates.includes(row.worktree) && row.time_initialized !== null)?.time_initialized + } finally { + db.close() + } + }) + +const expectStamped = (home: string, what: string) => + pollWithTimeout(stampOf(home), `${what} did not stamp project.time_initialized`, "10 seconds").pipe( + Effect.flatMap((stamp) => Effect.sync(() => expect(stamp).toBeGreaterThan(0))), + ) + +describe("headless /init stamps project.time_initialized (#404)", () => { + // The #404 repro: "/init" as a plain text prompt through the run CLI. + // RED before the fix (the text path never reached SessionPrompt.command). + cliIt.live( + 'opencode run "/init" stamps the project row', + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + yield* llm.text("AGENTS.md initialized") + const result = yield* opencode.run("/init", { env: { PWD: home, OPENCODE_DB: "" } }) + opencode.expectExit(result, 0) + yield* expectStamped(home, 'opencode run "/init"') + }), + 180_000, + ) + + // Control: the explicit --command flag always used the /command endpoint. + cliIt.live( + "opencode run --command init stamps the project row", + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + yield* llm.text("AGENTS.md initialized") + const result = yield* opencode.run("", { command: "init", env: { PWD: home, OPENCODE_DB: "" } }) + opencode.expectExit(result, 0) + yield* expectStamped(home, "opencode run --command init") + }), + 180_000, + ) + + // Control: the /command endpoint (what the TUI uses) stamps in a fully + // headless serve process — proves the stamp chain works without a TUI. + cliIt.live( + "serve POST /session/:id/command stamps the project row", + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + const server = yield* opencode.serve({ env: { OPENCODE_DB: "" } }) + const client = yield* HttpClient.HttpClient + const headers = { "x-opencode-directory": encodeURIComponent(home) } + + yield* llm.text("AGENTS.md initialized") + const created = yield* client.post(`${server.url}/session`, { body: HttpBody.jsonUnsafe({}), headers }) + const session = Schema.decodeUnknownSync(SessionRef)(yield* created.json) + const res = yield* client.post(`${server.url}/session/${session.id}/command`, { + body: HttpBody.jsonUnsafe({ command: "init", arguments: "" }), + headers, + }) + expect(res.status).toBe(200) + yield* expectStamped(home, "serve /command init") + }), + 240_000, + ) + + // The #404 serve repro: "/init" as a plain text part at the message endpoint. + // RED before the fix (the stamp landed only via the /command endpoint). + cliIt.live( + 'serve POST /session/:id/message with text "/init" stamps the project row', + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + const server = yield* opencode.serve({ env: { OPENCODE_DB: "" } }) + const client = yield* HttpClient.HttpClient + const headers = { "x-opencode-directory": encodeURIComponent(home) } + + yield* llm.text("AGENTS.md initialized") + const created = yield* client.post(`${server.url}/session`, { body: HttpBody.jsonUnsafe({}), headers }) + const session = Schema.decodeUnknownSync(SessionRef)(yield* created.json) + const res = yield* client.post(`${server.url}/session/${session.id}/message`, { + body: HttpBody.jsonUnsafe({ parts: [{ type: "text", text: "/init" }] }), + headers, + }) + expect(res.status).toBe(200) + yield* expectStamped(home, 'serve message "/init"') + }), + 240_000, + ) +})