Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 4cd758ffb2 docs: simplify V2 session audit guidance 2026-07-03 22:29:59 -04:00
Kit Langton c411ea8045 fix(tui): reconcile session state after reconnect 2026-07-03 22:10:01 -04:00
4 changed files with 143 additions and 10 deletions
+39 -1
View File
@@ -1,6 +1,6 @@
---
name: debug-opencode
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
description: Use when running, debugging, auditing, or verifying opencode's V2 CLI/TUI or server, including installed opencode2 sessions, termctrl reproduction, server APIs, logs, and Bun's inspector.
---
# Debugging opencode itself
@@ -104,6 +104,44 @@ bun dev api <operationId> --param key=value
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing an installed `opencode2` session
When given a real `ses_...` ID, first identify the installed service and channel database. `opencode2 api` may start a daemon when none is healthy, so inspect the registration before calling it.
```bash
opencode2 --version
opencode2 service status
jq '{id,version,url,pid}' ~/.local/state/opencode/service.json
ps -p "$(jq -r .pid ~/.local/state/opencode/service.json)" -o pid=,command=
```
The installed `next` channel normally uses `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`. `OPENCODE_DB` can override the database. Locate an uncertain database by Session ID without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
Compare three sources before diagnosing the owner of a bug:
- The live API for the current daemon's state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
```bash
opencode2 api get /api/session/active
opencode2 api get "/api/session/$SESSION"
opencode2 api get "/api/session/$SESSION/message"
DB=~/.local/share/opencode/opencode-next.db
sqlite3 -separator $'\t' "file:$DB?mode=ro" \
"select seq,type,datetime(created/1000,'unixepoch'),json_extract(data,'$.assistantMessageID'),json_extract(data,'$.callID'),json_extract(data,'$.finish'),json_extract(data,'$.error.message') from event where aggregate_id='$SESSION' order by seq;"
```
Select event type, IDs, status, timestamps, and short text fields rather than dumping image data or encrypted reasoning blobs.
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
+2 -1
View File
@@ -20,7 +20,7 @@ import type {
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js"
@@ -840,6 +840,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
setStore("session", "status", reconcile({}))
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
+10 -7
View File
@@ -69,11 +69,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const appendMessage = (messageID: string) =>
const appendMessage = (messageID: string, queued = isQueued(messageID), promoted = false) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const queued = isQueued(messageID)
const existing = draft.findIndex((row) => row.type === "message" && row.messageID === messageID)
if (existing !== -1) {
if (!promoted) return
draft.splice(existing, 1)
}
const index = queued ? draft.length : queuedStart(draft)
if (!queued) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID })
@@ -129,12 +132,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
const message = (event: { id: string; data: { sessionID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
}
const input = (event: { data: { sessionID: string; inputID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
const input = (event: { data: { sessionID: string; inputID: string } }, queued: boolean, promoted = false) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID, queued, promoted)
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.prompt.promoted", input),
data.on("session.prompt.admitted", (event) => input(event, true)),
data.on("session.prompt.promoted", (event) => input(event, false, true)),
data.on("session.context.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
+92 -1
View File
@@ -8,6 +8,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data"
import { createSessionRows } from "../../../src/routes/session/rows"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -109,12 +110,19 @@ test("refreshes resources into reactive getters", async () => {
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { event: 0, model: 0 }
const requests = { active: 0, event: 0, model: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/event") {
requests.event++
return events.v2()
}
if (url.pathname === "/api/session/active") {
requests.active++
return json({
data: requests.active === 1 ? { "session-stale": { type: "running" } } : {},
watermarks: {},
})
}
if (url.pathname !== "/api/model") return
requests.model++
return json({
@@ -157,6 +165,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
try {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
await wait(() => data.session.status("session-stale") === "running")
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
@@ -166,6 +175,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
expect(data.connection.error()).toBe("Event stream disconnected")
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
expect(requests.event).toBe(2)
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
@@ -357,6 +367,87 @@ test("tracks session status from active sessions and execution events", async ()
}
})
test("completes exploration when a queued prompt is promoted", async () => {
const events = createEventStream()
const sessionID = "session-promotion"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let rows!: ReturnType<typeof createSessionRows>
function Probe() {
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
emitEvent(events, {
id: "evt_step_started",
created: 1,
type: "session.step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: "message-assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitEvent(events, {
id: "evt_tool_started",
created: 2,
type: "session.tool.input.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "call-read",
name: "read",
},
})
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
emitEvent(events, {
id: "evt_prompt_admitted",
created: 3,
type: "session.prompt.admitted",
durable: durable(sessionID, 2),
data: {
sessionID,
inputID: "message-user",
prompt: { text: "Continue" },
delivery: "steer",
},
})
await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user"))
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
emitEvent(events, {
id: "evt_prompt_promoted",
created: 4,
type: "session.prompt.promoted",
durable: durable(sessionID, 3),
data: { sessionID, inputID: "message-user" },
})
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
} finally {
app.renderer.destroy()
}
})
test("refreshes integrations after integration updates", async () => {
const events = createEventStream()
const requests = { integration: 0, model: 0, provider: 0 }