Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
})
Expand All @@ -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 })
Expand Down
150 changes: 150 additions & 0 deletions packages/opencode/test/cli/run/headless-init.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
Loading