Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/contracts/src/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/threads/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/test/claude.progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions packages/ui/src/components/TurnSummary.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -77,9 +79,8 @@
<LoaderCircle size={18} class="spinner compaction-icon" aria-hidden="true" />
<div class="compaction-copy">
<span class="compaction-title ui-label">{strings.chat.compacting}</span>
<!-- Provider activity timestamps do not tell us when a mid-turn compaction began. -->
{#if compactOperation && spent !== null}
<span class="compaction-elapsed ui-label" data-testid="compaction-elapsed">{fill(strings.chat.compactionElapsed, { time: elapsed(spent) })}</span>
{#if compactionSpent !== null}
<span class="compaction-elapsed ui-label" data-testid="compaction-elapsed">{fill(strings.chat.compactionElapsed, { time: elapsed(compactionSpent) })}</span>
{/if}
</div>
{:else}
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/components/TurnSummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ test('mid-turn compaction hides reply activity without labelling the whole turn
expect(document.querySelector('[data-testid=turn-provider-signal]')).toBeNull();
});

test('mid-turn compaction counts from when the compacting phase began', () => {
vi.useFakeTimers({ now: STARTED + 300_000 });
running = mount(TurnSummary, { target: document.body, props: {
turn: turn({ status: 'running', finishedAt: null }),
progress: { turnId: 'turn-1', phase: 'compacting', detail: null, at: Date.now() - 1_000, since: Date.now() - 83_000, providerAt: Date.now() },
} });
flushSync();
expect(text('compaction-elapsed')).toBe('Elapsed: 1m 23s');
vi.advanceTimersByTime(2_000);
flushSync();
expect(text('compaction-elapsed')).toBe('Elapsed: 1m 25s');
});

test.each([
['done', 'Context compacted', 'Compacted in 4m 41s'],
['error', 'Compaction failed', 'Elapsed: 4m 41s'],
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/lib/fake-client/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function observeProgress<E extends RpcEventName>(ctx: FakeContext, thread
const phase: ThreadProgress['phase'] = part?.type === 'thinking' ? 'thinking' : part?.type === 'tool' && part.status === 'running' ? 'tool' : 'working';
const detail = part?.type === 'tool' && part.status === 'running' ? part.name : null;
const old = thread.progress;
thread.progress = { turnId: turn.id, phase, detail, at: ctx.now(), providerAt: ctx.now() };
const since = old?.turnId === turn.id && old.phase === phase ? old.since ?? old.at : ctx.now();
thread.progress = { turnId: turn.id, phase, detail, at: ctx.now(), since, providerAt: ctx.now() };
const times = published.get(ctx) ?? new Map<ThreadId, number>();
published.set(ctx, times);
if (old?.phase !== phase || old.detail !== detail || ctx.now() - (times.get(threadId) ?? 0) >= 1000) {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/lib/fake-client/working-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function seedWorking(ctx: FakeContext, count: number): () => void {
ctx.emit('thread.load', { threadId: id, load: structuredClone(thread.load) });
const at = Date.now();
const detail = PROGRESS[(tick + index) % PROGRESS.length] ?? null;
ctx.emit('thread.updated', { ...toSummary(thread), progress: { turnId: thread.turns[0]!.id, phase: detail === null ? 'thinking' : 'tool', detail, at, providerAt: at } });
ctx.emit('thread.updated', { ...toSummary(thread), progress: { turnId: thread.turns[0]!.id, phase: detail === null ? 'thinking' : 'tool', detail, at, since: at, providerAt: at } });
}
}, 1000);
return () => clearInterval(timer);
Expand Down
Loading