Skip to content

Commit 47e0279

Browse files
authored
fix(session): clear a queued follow-up's badge when it is delivered (#1117)
A follow-up sent while the agent works (follow-up behaviour "queue") shows the "Queued" badge until the engine delivers it. After some turns the badge never cleared: the message was answered, the engine went idle, and only closing and reopening the session removed it (#1112). The stale pending entry also kept the message pinned below the running turn. The session view lists queued prompts from the session reducer's inbox through a memo. CodeNomad replaces that reducer while the session stays open: an authoritative resync after an event-queue overflow, a rotation failure or a reconnect, and a pruning invalidation. The replacement was a plain property write, so the memo kept reading the disposed reducer, which never sees `session.inbox.delivered`. On 0.20.1 nothing rebound it until the view remounted; on dev the reducer retirement at settlement happened to rebind it at the end of the turn, but the badge stayed for the rest of the turn, and indefinitely when retirement waits (another prompt still parked, an unsettled tool or shell). Every replacement now goes through replaceTranscriptEntry, which assigns the new reducer, disposes the previous one and bumps a per-transcript entryRevision signal that getOpenCodeSessionInbox tracks. Only that session's inbox observers re-run; a transcript destroyed and recreated is still rebound by the instance revision. Several queued follow-ups keep their badges until each is delivered; steer-mode actions on a delivered prompt disappear the same way. Validation: opencode-inbox-rebinding.test.ts queues two follow-ups, replaces the reducer by pruning invalidation and by an event-queue overflow, then delivers them one by one with no terminal event: each leaves the memo at once. Both cases fail without the fix. A browser test in session-rendering.test.ts runs the real SessionView and SSE dispatcher: queued badge, a pruning event, delivery, badge gone while the turn still runs; it fails without the fix with the badge still visible. The session fixture's native inbox now lists parked prompts as an array and stubs the permission and form lists, as a real daemon does, so an authoritative resync completes. The new unit file is listed in the PR build's UI unit step, which passes locally (64 files, 562 tests), as do the UI own-tsconfig and root typechecks and the six browser suites using the session fixture (206 tests).
1 parent 7f1ee6c commit 47e0279

6 files changed

Lines changed: 138 additions & 8 deletions

File tree

‎.github/workflows/pr-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ jobs:
438438
packages/ui/src/stores/opencode-data-idle.test.ts
439439
packages/ui/src/stores/opencode-data-settlement.test.ts
440440
packages/ui/src/stores/opencode-data-reconnect.test.ts
441+
packages/ui/src/stores/opencode-inbox-rebinding.test.ts
441442
packages/ui/src/stores/opencode-transcript-replay.test.ts
442443
packages/ui/src/stores/opencode-compaction.test.ts
443444
packages/ui/src/lib/message-part-grouping.test.ts

‎dev-docs/TRANSCRIPT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ Message content visibility and presets, token lines, tool renderers, native sess
1212
- Code Mode reads native `metadata.toolCalls` in `tool-call/renderers/execute-data.ts`; keep call-list styling in `styles/messaging/tool-call/execute.css`, with native image/output ownership in the shared tool shell.
1313
- Native websearch text is parsed conservatively by `tool-call/renderers/websearch-data.ts`; unknown/oversized responses use the shared raw renderer. Recognized provider-consent Forms retain the generic schema/answer path, with layout in `styles/messaging/tool-call/websearch.css`.
1414
- Session lifecycle features use OpenCode V2's published signals: one `session.execution.{succeeded,failed,interrupted}` per busy period (`lib/native-execution-events.ts`) and `session.retry.scheduled` for provider backoff. The schema-declared `session.idle`/`session.status` are never published; never wire a feature only to them. A terminal event is a hint until `session.active` confirms settlement, which alone announces "A session finished", only for root sessions ending in success, failure or an inactivity stop. Transcript reducers retire at settlement, deferred while prompts stay parked in their inbox or a user shell or unsettled tool call still needs reconciling there. Token/cost lines (`components/message-usage.tsx`) appear once per agent message: the row of an assistant message's final text (the one carrying "delete tools and reasoning") renders, once that message has native usage, the sum (`sumMessageUsage`) of its own usage and of every message holding a tool or reasoning part that cleanup selects (`technicalCleanupParts`). Reuse that selection rather than a separate projection. The line renders inside the content row, so it never shifts `<Index>` items or splits a technical group (whose Delete needs two tools); tool-only steps get no line. A message whose text is followed by tools also counts in the next message's line, as its tools belong to that selection. Sums cover the loaded transcript window only, like cleanup. Capture real streams with `scripts/test-session-lifecycle-native.mjs`.
15-
- Native transcript events reduce only through `stores/opencode-data.ts` (never add another partial-part reducer), which publishes bounded snapshots through `hydrateMessages` (message-v2/instance-store.ts); these per-event re-projections must not invalidate unchanged messages: `hydrateMessages` reuses the resident `MessageRecord` object whenever content, explicit timestamps and prompt-display metadata are unchanged, and an identical whole snapshot returns before any store write or session-revision bump. Part/info comparisons use allocation-free structural equality (`partDataEqual`), never `JSON.stringify`. The SDK reduce mutates rows in place, so reference identity can never prove a session unchanged — never add a reference-anchored projection skip.
15+
- Native transcript events reduce only through `stores/opencode-data.ts` (never add another partial-part reducer), which publishes bounded snapshots through `hydrateMessages` (message-v2/instance-store.ts); these per-event re-projections must not invalidate unchanged messages: `hydrateMessages` reuses the resident `MessageRecord` object whenever content, explicit timestamps and prompt-display metadata are unchanged, and an identical whole snapshot returns before any store write or session-revision bump. Part/info comparisons use allocation-free structural equality (`partDataEqual`), never `JSON.stringify`. The SDK reduce mutates rows in place, so reference identity can never prove a session unchanged — never add a reference-anchored projection skip. A session's reducer is replaced (authoritative resync after an overflow, rotation failure or reconnect; pruning invalidation) only through `replaceTranscriptEntry`, which bumps the transcript's `entryRevision`; reactive readers of the reducer, such as the inbox behind queued badges (`getOpenCodeSessionInbox`), track it so they rebind instead of keeping the disposed reducer's last state (`opencode-inbox-rebinding.test.ts`, `tests/browser/session-rendering.test.ts`).
1616
- Shared speech byte/MediaSource transport lives in `lib/audio-utils.ts`; playback ownership and cancellation stay with its callers.

‎packages/ui/src/stores/opencode-data.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ export type OpenCodeDataEventAdmission = {
4848
}
4949

5050
type TranscriptEntry = {
51+
/** Replaced only through replaceTranscriptEntry, which bumps entryRevision. */
5152
entry: DataEntry
53+
/** Tracked by reactive readers of `entry` (the inbox), which a replacement must rebind. */
54+
entryRevision: ReturnType<typeof createSignal<number>>
5255
directory: string
5356
generation: number
5457
rotating: boolean
@@ -269,6 +272,7 @@ function ensureTranscript(instanceId: string, sessionId: string, directory: stri
269272
if (existing) return existing
270273
const transcript: TranscriptEntry = {
271274
entry: createDataEntry(instanceId, directory),
275+
entryRevision: createSignal(0),
272276
directory,
273277
generation: getOpenCodeInstanceGeneration(instanceId),
274278
rotating: false,
@@ -286,6 +290,15 @@ function ensureTranscript(instanceId: string, sessionId: string, directory: stri
286290
return transcript
287291
}
288292

293+
/** Disposes the transcript's reducer for `next`. Readers still bound to the old
294+
* one would keep its last state, e.g. a delivered prompt shown as queued. */
295+
function replaceTranscriptEntry(transcript: TranscriptEntry, next: DataEntry): void {
296+
const previous = transcript.entry
297+
transcript.entry = next
298+
previous.dispose()
299+
transcript.entryRevision[1]((value) => value + 1)
300+
}
301+
289302
function getOpenCodeFullDataRevision(instanceId: string, sessionId: string): number {
290303
return fullDataRevisions.get(messageRevisionKey(instanceId, sessionId)) ?? 0
291304
}
@@ -443,15 +456,13 @@ async function resyncAuthoritativeTranscript(
443456
return
444457
}
445458

446-
const previous = transcript.entry
447-
transcript.entry = fresh
448459
transcript.freshEntry = undefined
449460
transcript.needsAuthoritativeResync = false
450461
transcript.reconnectResync = false
451462
transcript.resyncing = false
452463
transcript.retryCount = 0
453464
swapped = true
454-
previous.dispose()
465+
replaceTranscriptEntry(transcript, fresh)
455466

456467
transcript.onResynced?.(fresh.data)
457468
if (!isTranscriptCurrent(instanceId, sessionId, transcript) || transcript.entry !== fresh) return
@@ -751,8 +762,7 @@ export function invalidateOpenCodeSessionContent(instanceId: string, sessionId:
751762
// Discard both pending rotations and the old SDK projection. Invalidating
752763
// just the visible page allows the next native event to restore stale parts.
753764
invalidateTranscript(transcript)
754-
transcript.entry.dispose()
755-
transcript.entry = createDataEntry(instanceId, transcript.directory)
765+
replaceTranscriptEntry(transcript, createDataEntry(instanceId, transcript.directory))
756766
// The pruning handler (or the next session open) reloads the native UI page
757767
// together with its cursors. SDK reconciliation still repairs its independent
758768
// projection, but its 20-message seed cannot replace that 200-message page.
@@ -766,7 +776,9 @@ export function getOpenCodeMutationRevision(instanceId: string, sessionId: strin
766776

767777
export function getOpenCodeSessionInbox(instanceId: string, sessionId: string, directory: string) {
768778
instanceDataRevision(instanceId)[0]()
769-
return ensureTranscript(instanceId, sessionId, directory).entry.data.session.pending.list(sessionId)
779+
const transcript = ensureTranscript(instanceId, sessionId, directory)
780+
transcript.entryRevision[0]()
781+
return transcript.entry.data.session.pending.list(sessionId)
770782
}
771783

772784
export async function syncOpenCodeSessionInbox(instanceId: string, sessionId: string, directory: string): Promise<void> {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
import { createMemo, createRoot } from "solid-js"
4+
import { applyOpenCodeDataEvent, destroyOpenCodeData, getOpenCodeSessionInbox, invalidateOpenCodeSessionContent } from "./opencode-data.ts"
5+
import { getRootClient } from "./opencode-client.ts"
6+
import { sdkManager } from "../lib/sdk-manager.ts"
7+
8+
async function waitFor(done: () => boolean, message: string): Promise<void> {
9+
for (let turn = 0; turn < 500 && !done(); turn++) await new Promise<void>((resolve) => setTimeout(resolve, 0))
10+
assert.ok(done(), message)
11+
}
12+
13+
/** Replaces the session's reducer the way a pruning event or an overflowing event queue does. */
14+
const triggers = {
15+
prune: (instanceId: string, sessionId: string) => invalidateOpenCodeSessionContent(instanceId, sessionId),
16+
overflow: (instanceId: string, sessionId: string) => {
17+
const step = (id: string, created: number) => applyOpenCodeDataEvent(instanceId, "/work", {
18+
id, type: "session.step.started", created,
19+
data: { sessionID: sessionId, assistantMessageID: id, agent: "build", model: { providerID: "provider", id: "model" } },
20+
} as any)
21+
for (let index = 0; index < 199; index++) step(`seed-${index}`, 10 + index)
22+
for (let index = 0; index < 4096; index++) step(`overflow-${index}`, 500 + index)
23+
},
24+
}
25+
26+
describe("OpenCode session inbox", () => {
27+
for (const [name, replace] of Object.entries(triggers)) {
28+
it(`drops a delivered follow-up after a ${name} replaces the session reducer, before the turn ends`, async () => {
29+
const instanceId = `opencode-inbox-rebinding-${name}`
30+
const sessionId = "session"
31+
const client = getRootClient(instanceId) as any
32+
// The native inbox: what an authoritative resync lists.
33+
const native: any[] = []
34+
client.session.get = async () => ({
35+
id: sessionId, title: sessionId, projectID: "project", location: { directory: "/work" },
36+
time: { created: 1, updated: 1 }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
37+
})
38+
client.session.active = async () => ({ [sessionId]: {} })
39+
client.session.inbox = { list: async () => native.map((item) => ({ ...item })) }
40+
client.permission.list = async () => []
41+
client.session.form.list = async () => []
42+
client.message.list = async () => ({ data: [], cursor: {} })
43+
const queue = (id: string, created: number) => {
44+
native.push({ id, sessionID: sessionId, type: "user", payload: { text: id }, delivery: "queue", time: { created } })
45+
applyOpenCodeDataEvent(instanceId, "/work", {
46+
id: `enqueued-${id}`, type: "session.inbox.enqueued", created,
47+
data: { sessionID: sessionId, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "queue" } },
48+
} as any)
49+
}
50+
const deliver = (id: string, created: number) => {
51+
native.splice(native.findIndex((item) => item.id === id), 1)
52+
applyOpenCodeDataEvent(instanceId, "/work", {
53+
id: `delivered-${id}`, type: "session.inbox.delivered", created, data: { sessionID: sessionId, inboxID: id },
54+
} as any)
55+
}
56+
// The session view's queued badges read this memo.
57+
let queued!: () => string[]
58+
const dispose = createRoot((dispose) => {
59+
queued = createMemo(() => getOpenCodeSessionInbox(instanceId, sessionId, "/work").map((item) => item.id))
60+
return dispose
61+
})
62+
try {
63+
queue("first", 1)
64+
queue("second", 2)
65+
assert.deepEqual(queued(), ["first", "second"])
66+
67+
replace(instanceId, sessionId)
68+
await waitFor(() => queued().length === 2, "the resynced reducer still lists both follow-ups")
69+
await new Promise<void>((resolve) => setTimeout(resolve, 50))
70+
assert.deepEqual(queued(), ["first", "second"])
71+
72+
deliver("first", 5000)
73+
assert.deepEqual(queued(), ["second"], "only the follow-up still waiting keeps its badge")
74+
deliver("second", 5001)
75+
assert.deepEqual(queued(), [])
76+
} finally {
77+
dispose()
78+
destroyOpenCodeData(instanceId)
79+
sdkManager.destroyClientsForInstance(instanceId)
80+
}
81+
})
82+
}
83+
})

‎packages/ui/tests/browser/fixtures/session.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ let time = Date.now(), calls = 0
2020
let delayPrompt = false, admittedPrompt: any
2121
let releasePrompt: (() => void) | undefined
2222
const nativeMessages: any[] = []
23+
/** Prompts parked in the native inbox, as an authoritative resync lists them. */
24+
const nativeInbox: any[] = []
2325
const model = { providerID: "fixture", id: "fixture" }
2426
const emit = (type: string, data: any) => (sseManager as any).handleEvent(instanceId, {
2527
id: `ev_${++time}`, type, created: time, location: { directory: "/fixture" },
2628
data: { sessionID: sessionId, ...data },
2729
})
2830
const client: any = {
2931
session: {
30-
active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
32+
active: async () => ({}), inbox: { list: async () => nativeInbox.map(item => ({ ...item })) },
33+
form: { list: async () => [] },
3134
get: async () => ({ id: sessionId, title: "fixture", location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
3235
instructions: { entry: { remove: async () => {}, put: async () => {} } },
3336
switchAgent: async (data: any) => emit("session.agent.selected", data),
@@ -46,6 +49,7 @@ const client: any = {
4649
},
4750
},
4851
model: { default: async () => ({ data: model }) },
52+
permission: { list: async () => [] },
4953
message: { list: async ({ cursor, limit = 200 }: { cursor?: string; limit?: number } = {}) => {
5054
calls++
5155
const offset = Number(cursor ?? 0), messages = nativeMessages.slice().reverse()
@@ -79,6 +83,19 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider>
7983
emit("session.inbox.delivered", { inboxID: admittedPrompt.id })
8084
},
8185
reload: () => loadMessages(instanceId, sessionId, { force: true }),
86+
/** Parks a follow-up in the native inbox while a turn runs (follow-up behaviour "queue"). */
87+
queueFollowUp: (id: string, text: string) => {
88+
nativeInbox.push({ id, sessionID: sessionId, type: "user", payload: { text }, delivery: "queue", time: { created: ++time } })
89+
emit("session.inbox.enqueued", { inboxID: id, item: { type: "user", payload: { text }, delivery: "queue" } })
90+
},
91+
/** The engine starts the parked follow-up: its native row persists and it leaves the inbox. */
92+
deliverQueued: (id: string) => {
93+
const [item] = nativeInbox.splice(nativeInbox.findIndex(entry => entry.id === id), 1)
94+
nativeMessages.push({ type: "user", text: item.payload.text, id, time: { created: ++time } })
95+
emit("session.inbox.delivered", { inboxID: id })
96+
},
97+
/** A history rewrite (pruning) replaces the session's reducer and reloads the page. */
98+
prune: () => emit("rpc.codenomad.session-pruning.pruned", { messageID: assistantId, revision: "a".repeat(64) }),
8299
admitted: () => admittedPrompt?.id,
83100
seedHistory: async (count = 60) => {
84101
for (let i = 0; i < count; i++) {

‎packages/ui/tests/browser/session-rendering.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,23 @@ test("a real send stays rendered across delayed admission, inbox echo and author
518518
})
519519
})
520520

521+
test("a queued follow-up loses its badge when delivered after a history rewrite, while the turn still runs", async () => {
522+
await open("session", async page => {
523+
const marker = "Queued follow-up outlives a history rewrite"
524+
await page.evaluate(() => (window as any).fixture.start())
525+
await page.evaluate(text => (window as any).fixture.queueFollowUp("msg_followup", text), marker)
526+
const badge = page.locator(".message-stream-block").filter({ hasText: marker }).locator(".message-queued-badge")
527+
await badge.waitFor()
528+
// Pruning replaces the session's reducer; the follow-up is still parked natively.
529+
await page.evaluate(() => (window as any).fixture.prune())
530+
await page.waitForTimeout(500)
531+
await badge.waitFor()
532+
await page.evaluate(() => (window as any).fixture.deliverQueued("msg_followup"))
533+
await badge.waitFor({ state: "detached", timeout: 3000 })
534+
assert.equal(await page.locator(".message-stream-block").filter({ hasText: marker }).count(), 1, "the delivered follow-up stays rendered")
535+
})
536+
})
537+
521538
test("SessionView retains an escaped position inside a long assistant reply on return", async () => {
522539
await open("session", async page => {
523540
const prompt = page.locator("textarea:visible").first()

0 commit comments

Comments
 (0)