Skip to content
33 changes: 33 additions & 0 deletions .github/releases/v1.0.30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## opencode {VERSION}

{Prerelease/Stable} release from `{branch}` branch. Headless `/init` now stamps project initialization, so MEMORY works from `opencode run` and SDK text prompts, not just the TUI.

---

### 🐛 Bug Fixes

- **Headless `/init` never stamped project initialization, #404 (PR #405)**: `opencode run "/init"` and serve `POST /session/:id/message` text prompts delivered slash commands as plain text - command routing existed only in the TUI client, so `SessionPrompt.command` never ran, `Command.Event.Executed` never fired, and `project.time_initialized` stayed NULL, leaving MEMORY fail-closed inert with no hint. The server-side prompt endpoints (`prompt` and `promptAsync`) now route single-text-part prompts that name a registered command through the command lifecycle; a registry miss falls through to the plain prompt, preserving existing text-prompt semantics. The TUI `/command` path is unchanged.

---

### 🧪 Test Summary

```
CI gates on the dev-to-main promotion:
Typecheck: pass
Unit Tests (linux): pass
E2E Tests (linux): pass
E2E Tests (windows): pass
SpecGit Acceptance: pass
```

---

### 🔍 Verification

- The regression is pinned by `test/cli/run/headless-init.test.ts`: four subprocess arms - `opencode run "/init"` and serve message text `/init` (both RED on the parent commit), plus `run --command init` and serve `/command` controls - assert a non-NULL `time_initialized` in the isolated project database.
- End-to-end verification spawned the CLI from source on a fresh git repo with an isolated HOME and confirmed the stamp lands on the `opencode run "/init"` path; the fix was delivered through the specgit harness with an accepted verdict on PR #405.

---

**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag})
9 changes: 4 additions & 5 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
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
pr: 405
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