feat(nomos): chat working-visibility, message queue, generation-aware timeline
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user