diff --git a/packages/contracts/src/threads.ts b/packages/contracts/src/threads.ts index 6b2616078..e13f67954 100644 --- a/packages/contracts/src/threads.ts +++ b/packages/contracts/src/threads.ts @@ -91,6 +91,8 @@ export interface ThreadProgress { phase: 'starting' | 'thinking' | 'compacting' | 'retrying' | 'tool' | 'working' | 'waiting'; detail: string | null; at: Timestamp; + /** When the current phase began: `at` of its first event in a row. An older core leaves it out. */ + since?: Timestamp; /** Latest provider signal, including status notifications that do not advance activity. */ providerAt?: Timestamp | null; } diff --git a/packages/core/src/threads/progress.ts b/packages/core/src/threads/progress.ts index 7842a023f..1530128dc 100644 --- a/packages/core/src/threads/progress.ts +++ b/packages/core/src/threads/progress.ts @@ -20,7 +20,8 @@ export class ProgressState { begin(threadId: ThreadId, turnId: TurnId): void { const old = this.latest.get(threadId); if (old) this.running.delete(old.turnId); - this.latest.set(threadId, { turnId, phase: 'starting', detail: null, at: Date.now() }); + const at = Date.now(); + this.latest.set(threadId, { turnId, phase: 'starting', detail: null, at, since: at }); this.published.delete(threadId); // A turn starts queued and turns running in a later write; one already running is read once here. if (this.core.journal.getTurn(turnId)?.status === 'running') this.running.add(turnId); @@ -37,7 +38,9 @@ export class ProgressState { if (old?.turnId !== turnId || !this.running.has(turnId)) return; const at = Date.now(); const short = detail?.replace(/\s+/g, ' ').trim().slice(0, 300) || null; - this.latest.set(threadId, { turnId, phase, detail: short, at, providerAt: at }); + // A compaction mid-turn shows how long it has run, so a repeated phase keeps its start. + const since = old.phase === phase ? old.since ?? old.at : at; + this.latest.set(threadId, { turnId, phase, detail: short, at, since, providerAt: at }); if (old.phase === phase && old.detail === short && at - (this.published.get(threadId) ?? 0) < 1000) return; const thread = this.core.journal.getThread(threadId); if (!thread) return; diff --git a/packages/core/test/claude.progress.test.ts b/packages/core/test/claude.progress.test.ts index d79a837aa..721b28331 100644 --- a/packages/core/test/claude.progress.test.ts +++ b/packages/core/test/claude.progress.test.ts @@ -17,6 +17,15 @@ test('a silent Claude compaction is observable before the turn answers', async ( await Bun.sleep(20); const thread = await client.call('threads.get', { threadId }); expect(thread.progress).toMatchObject({ turnId: turn.id, phase: 'compacting', detail: null }); + // The phase keeps its start across repeated events and restarts on the next phase. + const since = thread.progress!.since!; + await Bun.sleep(5); + harness.core.threads.progress.report(threadId, turn.id, 'compacting'); + const repeated = harness.core.threads.progress.get(threadId)!; + expect(repeated.since).toBe(since); + expect(repeated.at).toBeGreaterThan(since); + harness.core.threads.progress.report(threadId, turn.id, 'working'); + expect(harness.core.threads.progress.get(threadId)!.since).toBeGreaterThan(since); expect(thread.messages.filter(message => message.role === 'assistant')).toHaveLength(0); queries[0]!.emit(success('sess-progress')); await finished; diff --git a/packages/ui/src/components/TurnSummary.svelte b/packages/ui/src/components/TurnSummary.svelte index 1489571fd..922df5bec 100644 --- a/packages/ui/src/components/TurnSummary.svelte +++ b/packages/ui/src/components/TurnSummary.svelte @@ -51,6 +51,8 @@ const spent = $derived(since === null ? null : Math.max(0, (turn.finishedAt ?? now) - since)); /** This turn's own share, shown on hover when the request spans several turns. */ const ownLabel = $derived(own !== null && spent !== null && spent - own >= 1000 ? fill(strings.chat.turnOwnTime, { time: elapsed(own) }) : undefined); + /** A compaction inside a turn counts from when its phase began, which an older core does not send. */ + const compactionSpent = $derived(compactOperation ? spent : observed?.since == null ? null : Math.max(0, now - observed.since)); const elapsedLabel = $derived(compactOperation ? turn.status === 'done' ? strings.chat.compactedFor : strings.chat.compactionElapsed : running ? strings.chat.workingFor : strings.chat.workedFor); const usage = $derived(turn.usage); const total = $derived(usage === null ? 0 : totalTokens(usage)); @@ -77,9 +79,8 @@