feat(nomos): chat working-visibility, message queue, generation-aware timeline
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Make background/long/desynced turns visible and queueable, fixing the four
symptoms that survived the v0.15.0 chat reliability pass.

F1 - status-driven working signal (workspace.ts taskWorking/currentWorking =
streaming OR status in {planning,executing}). Drives the chat trace, indicator,
and activity spinner so a turn with no live stream (background resume, a dropped
SSE, an idle-close mid long turn) still looks alive.

F2 - operator messages sent during an in-flight turn are now QUEUED and
auto-run when the gate frees, replacing the "still finishing a previous step...
send it again" rejection. Per-session in-memory FIFO (messagequeue.go, capped at
20) drained one-at-a-time under the turn gate; a `queued` SSE event drives a
"Queued" hint. drainQueued releases via a per-iteration deferred closure so a
runChatTurn panic can't deadlock the session's gate.

F3 - SSE keepalive (12s `:keepalive` comment) in handleChat so 20-40s
inter-iteration gaps no longer trip a proxy/browser idle close (the desync root
cause). All SSE writes serialized through one mutex.

F4 - generation-aware activity timeline (only the last propose_plan renders;
superseded ones collapse to one "Earlier plan revised" marker; step-attribution
follows only the current generation) + debounced plan refetch on lifecycle
events so a missed plan.proposed self-heals.

Verified against the last session (23da10db: 6m33s turn, operator "status"
deferred at 19:48:05). go test ./cmd/nomos/ green (new messagequeue tests);
web vitest 72/72 (new F4 generation tests); vite build clean.

VERSION: 0.16.0 -> 0.17.0
This commit is contained in:
2026-08-03 22:34:14 +02:00
parent 757ef2f34b
commit 5b68bdc16c
17 changed files with 877 additions and 136 deletions

View File

@@ -46,6 +46,14 @@ function toolResult(name: string, id: string): NonNullable<ChatMessage['tools']>
return { type: 'tool_result', name, id, result: 'ok' }
}
function toolUse(
name: string,
id: string,
args?: Record<string, unknown>
): NonNullable<ChatMessage['tools']>[number] {
return { type: 'tool_use', name, id, args }
}
describe('computeActivityLog timestamps (P0.2)', () => {
it('uses the message created_at for persisted tool calls, not a fabricated spread', () => {
const created = '2026-07-29T20:08:10Z'
@@ -102,3 +110,65 @@ describe('computeActivityLog timestamps (P0.2)', () => {
expect(entries.find((e) => e.id === 's1')!.timestamp).toBe(new Date(started).getTime())
})
})
// F4 (plan 2026-08-03): the timeline must be generation-aware. A re-proposed
// task persists every generation's propose_plan / update_plan_step calls; before
// the fix that produced N "Proposed plan" entries and tagged current-gen tools
// with seqs inferred from superseded generations. Only the LAST propose_plan is
// the live plan; earlier ones collapse to one "Earlier plan revised" marker, and
// step-attribution only follows the current generation.
describe('computeActivityLog generation awareness (F4)', () => {
it('renders one Proposed plan + a revised marker, and attributes tools to the current gen only', () => {
const created = '2026-07-29T20:08:10Z'
// Generation 1: propose → step 1 running → run. Then generation 2 (re-plan).
const gen1 = msg({
id: 'm1',
created_at: created,
tools: [
toolUse('propose_plan', 'p1'),
toolUse('update_plan_step', 'u1', { seq: 1, status: 'running' }),
toolResult('run', 'r1')
]
})
const gen2 = msg({
id: 'm2',
created_at: created,
tools: [
toolUse('propose_plan', 'p2'),
toolUse('update_plan_step', 'u2', { seq: 1, status: 'running' }),
toolResult('run', 'r2')
]
})
// Current-generation plan step (gen 2), as fetchPlan (MAX generation) returns.
const steps: PlanStep[] = [
{ id: 's-gen2', seq: 1, title: 'Gen2 step', detail: '', status: 'done', started_at: created }
]
const entries = computeActivityLog([gen1, gen2], steps, null, new Map())
// Exactly one "Proposed plan" (the current generation's).
const proposals = entries.filter((e) => e.description === 'Proposed plan')
expect(proposals.length).toBe(1)
// One collapsed marker for the superseded generation(s).
expect(entries.filter((e) => e.description === 'Earlier plan revised').length).toBe(1)
// The current-gen run is tagged with step 1 (from gen2's update_plan_step).
const r2 = entries.find((e) => e.id === 'r2')
expect(r2, 'gen2 run entry should exist').toBeDefined()
expect(r2!.stepSeq).toBe(1)
// The superseded-gen run is NOT tagged with a current-gen step (its
// update_plan_step belonged to the replaced generation).
const r1 = entries.find((e) => e.id === 'r1')
expect(r1, 'gen1 run entry should exist').toBeDefined()
expect(r1!.stepSeq).toBeUndefined()
})
it('plan-less Q&A still attributes nothing to a step (no propose_plan at all)', () => {
const m = msg({ id: 'm1', tools: [toolResult('get_entity', 't1')] })
const entries = computeActivityLog([m], [], null, new Map())
expect(entries.filter((e) => e.description === 'Proposed plan')).toHaveLength(0)
expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined()
})
})