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
8 changes: 8 additions & 0 deletions frontend/src/components/TokenBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export function TokenBar({ tokenState }: Props) {
// Show only session total in that case — the agent context bar is meaningless.
const isCompleted = agentContext === 0 && sessionTotal > 0;

if (tokenState.compacting) {
return (
<div className="token-bar token-bar--compacting" aria-label="Compacting context">
<span className="token-bar-compacting-label">COMPACTING</span>
</div>
);
}

return (
<>
<button
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/__tests__/TokenBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makeState(overrides: Partial<TokenState> = {}): TokenState {
numTurns: 0,
turnIndex: 0,
numCompactions: 0,
compacting: false,
...overrides,
};
}
Expand Down Expand Up @@ -104,6 +105,16 @@ describe('TokenBar', () => {
expect(screen.getByText(/Session tokens/)).toBeTruthy();
});

it('shows compacting indicator when compacting is true', () => {
const { container } = render(
<TokenBar tokenState={makeState({ agentContext: 170000, turnIndex: 3, compacting: true })} />,
);
expect(container.querySelector('.token-bar--compacting')).toBeTruthy();
expect(screen.getByText(/COMPACTING/)).toBeTruthy();
// Normal token display should not be visible
expect(screen.queryByText(/170k/)).toBeNull();
});

it('expands detail panel on tap', () => {
render(
<TokenBar
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/pages/TodoDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ export function TodoDetailView() {
const res = await apiFetch(`/api/workload/items/${currentItem.id}/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: '' }),
body: JSON.stringify({
description: '',
title: currentItem.summary,
contextHints: currentItem.contextHints,
sources: currentItem.sources,
}),
});

if (!res.ok) {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/__tests__/DesktopChatView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ function createMockStore() {
numTurns: 0,
turnIndex: 0,
numCompactions: 0,
compacting: false,
},
progress: { blocks: {}, toolIndex: {} },
sendError: null,
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -3462,6 +3462,35 @@ textarea:focus {
animation: token-flash 0.8s ease-in-out infinite alternate;
}

.token-bar--compacting {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.6);
background: rgba(251, 191, 36, 0.15);
animation: compaction-pulse 1s ease-in-out infinite alternate;
cursor: default;
pointer-events: none;
}

.token-bar-compacting-label {
font-weight: 700;
font-size: var(--text-xxs);
letter-spacing: 0.12em;
text-transform: uppercase;
}

@keyframes compaction-pulse {
from {
opacity: 1;
border-color: rgba(251, 191, 36, 0.6);
box-shadow: 0 0 6px rgba(251, 191, 36, 0.3);
}
to {
opacity: 0.5;
border-color: rgba(251, 191, 36, 0.3);
box-shadow: 0 0 2px rgba(251, 191, 36, 0.1);
}
}

@keyframes token-flash {
from {
opacity: 1;
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types/ws-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export type ServerMessage =
| TaskUpdatedMsg
| TaskDeletedMsg
| TokenUpdateMsg
| CompactionStatusMsg
| LoopStatusMsg
| ProgressStartMsg
| ProgressUpdateMsg
Expand Down Expand Up @@ -301,6 +302,11 @@ export interface TokenUpdateMsg {
turnIndex: number;
}

export interface CompactionStatusMsg {
type: 'compaction_status';
active: boolean;
}

export interface LoopStatusMsg {
type: 'loop_status';
state: 'idle' | 'running' | 'paused';
Expand Down
18 changes: 18 additions & 0 deletions packages/client/__tests__/protocol-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,24 @@ describe('token_update', () => {
expect('sessionTotal' in r.tokensUpdate!).toBe(false);
expect('numTurns' in r.tokensUpdate!).toBe(false);
});

it('compaction_status sets compacting flag on tokensUpdate', () => {
const active = parseServerMessage(
{ type: 'compaction_status', active: true },
makeState(),
makeCallbacks(),
POOL_KEY,
);
expect(active.tokensUpdate).toEqual({ compacting: true });

const done = parseServerMessage(
{ type: 'compaction_status', active: false },
makeState(),
makeCallbacks(),
POOL_KEY,
);
expect(done.tokensUpdate).toEqual({ compacting: false });
});
});

// ─── Inbox ────────────────────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/protocol-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,10 @@ export function parseServerMessage(
break;
}

case 'compaction_status':

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: msg.active as boolean is a bare type assertion on an untyped message field. Other cases in this switch use similar casts (e.g., msg.agentContext as number), so this is consistent with the codebase, but a Boolean(msg.active) coercion would be more defensive against unexpected payloads. [fixable]

result.tokensUpdate = { compacting: Boolean(msg.active) };
break;

// Progress tracking messages
case 'progress_start':
result.progressUpdate = {
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/slices/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface TokensState {
numTurns: number;
turnIndex: number;
numCompactions: number;
compacting: boolean;
}

export const DEFAULT_CONTEXT_CEILING = 200_000;
Expand All @@ -16,4 +17,5 @@ export const INITIAL_TOKENS_STATE: TokensState = {
numTurns: 0,
turnIndex: 0,
numCompactions: 0,
compacting: false,
};
71 changes: 71 additions & 0 deletions server/__tests__/token-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,24 @@ describe('token_update emission', () => {
},
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
{ type: 'assistant', message: { content: [] }, session_id: 'sess-compact' },
// SDK compaction content block — start, delta, stop
{
type: 'stream_event',
event: {
type: 'content_block_start',
index: 1,
content_block: { type: 'compaction' },
},
},
{
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 1,
delta: { type: 'compaction_delta', content: 'Summary of prior context...' },
},
},
{ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } },
// SDK system status: compaction completed
{
type: 'system',
Expand Down Expand Up @@ -429,6 +447,59 @@ describe('token_update emission', () => {
// The final token_update should include numCompactions: 1

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: No test covers the safety-net path where the result event arrives while compacting is still true (i.e., SDK crashed or skipped the compact_result: 'success' system message). This would verify the warn log and the fallback compaction_status: active: false emission. The logic is straightforward but it's a defensive branch worth covering. [fixable]

const last = tokenUpdates[tokenUpdates.length - 1];
expect(last).toMatchObject({ numCompactions: 1 });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: The safety reset at query-loop.ts:573-581 (compaction flag reset on result when the SDK crashes/aborts before sending the system status success signal) has no test coverage. Only the happy path (content_block_start → system status success) is tested. A test with compaction started but result arriving without a preceding system status success would exercise the warn+reset path. [fixable]


// compaction_status events should bracket the compaction
const compactionStatuses = transport.sent.filter((m) => m.type === 'compaction_status');
expect(compactionStatuses).toHaveLength(2);
expect(compactionStatuses[0]).toMatchObject({ type: 'compaction_status', active: true });
expect(compactionStatuses[1]).toMatchObject({ type: 'compaction_status', active: false });
});

it('resets compacting flag on result when success signal is missing', async () => {
const events: Record<string, unknown>[] = [
{
type: 'stream_event',
parent_tool_use_id: null,
event: {
type: 'message_start',
message: { id: 'msg-crash', usage: { input_tokens: 180000 } },
},
},
{
type: 'stream_event',
event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } },
},
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
{ type: 'assistant', message: { content: [] }, session_id: 'sess-crash' },
// Compaction starts but SDK never sends compact_result: 'success'
{
type: 'stream_event',
event: {
type: 'content_block_start',
index: 1,
content_block: { type: 'compaction' },
},
},
{ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } },
// Result arrives directly — no compact_result system message
{
type: 'result',
session_id: 'sess-crash',
usage: { input_tokens: 180000, output_tokens: 500 },
total_cost_usd: 0.01,
num_turns: 1,
duration_ms: 3000,
duration_api_ms: 2000,
},
];

await runQueryLoop(eventStream(events), clientId, registry, abortController);

const compactionStatuses = transport.sent.filter((m) => m.type === 'compaction_status');
// Should have active:true from block_start and active:false from safety reset
expect(compactionStatuses).toHaveLength(2);
expect(compactionStatuses[0]).toMatchObject({ active: true });
expect(compactionStatuses[1]).toMatchObject({ active: false });
});

it('handles missing usage on message_start gracefully', async () => {
Expand Down
45 changes: 44 additions & 1 deletion server/__tests__/workload-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ describe('workload routes', () => {
expect(res.body.item.goalId).toBe(res.body.task.id);
});

it('POST /api/workload/items/:id/promote — returns 404 for missing item', async () => {
it('POST /api/workload/items/:id/promote — returns 404 for missing item with no body title', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The renamed test description 'returns 404 for missing item with no body title' is slightly awkward — it reads as though the body has 'no body title' rather than 'no title in body'. Consider 'returns 404 when item not found and body has no title'. [fixable]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: The test description was renamed to 'returns 404 for missing item with no body title' but the test body only sends { description: '' } — consider also adding a test that sends neither title nor description (empty body) to cover the pure 404 case. [fixable]

const res = await request(app)
.post('/api/workload/items/nonexistent-id/promote')
.set('Cookie', authCookie)
Expand All @@ -518,6 +518,49 @@ describe('workload routes', () => {
expect(res.status).toBe(404);
});

it('POST /api/workload/items/:id/promote — creates task from body fallback (Telos item)', async () => {
const res = await request(app)
.post('/api/workload/items/telos-item-123/promote')
.set('Cookie', authCookie)
.send({
title: 'Telos task title',
description: 'Extra context',
contextHints: {
repos: ['mitzo'],
taskHint: 'Check the API layer',
},
sources: [{ type: 'telos', url: 'https://example.com', title: 'Source doc' }],
});

expect(res.status).toBe(201);
expect(res.body.task).toMatchObject({
title: 'Telos task title',
status: 'pending',
});
expect(res.body.task.description).toContain('Extra context');
expect(res.body.task.description).toContain('Check the API layer');
expect(res.body.task.annotations).toEqual(
expect.arrayContaining([expect.stringContaining('Source doc')]),
);
expect(res.body.item).toBeUndefined();
});

it('POST /api/workload/items/:id/promote — fallback does not broadcast workload update', async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 missing_tests: The 'fallback does not broadcast workload update' test intercepts (app as any)._workloadBroadcast, but that property doesn't exist on the Express app. The actual broadcast function is the module-level onWorkloadBroadcast closure, set via setWorkloadBroadcast(). Since onWorkloadBroadcast is never set in this test suite, it's always null, so the test passes vacuously — it would pass even if the if (updatedItem) guard were removed. The assertion verifies the correct outcome but doesn't actually test the code path. [fixable]

const broadcasts: unknown[] = [];
const origBroadcast = (app as any)._workloadBroadcast;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Test 'fallback does not broadcast workload update' monkey-patches (app as any)._workloadBroadcast, but the actual broadcast callback is a module-scoped closure variable onWorkloadBroadcast set via setWorkloadBroadcast(). The test creates a property on the app object that nothing reads, so the assertion passes vacuously — the broadcast is never intercepted. Furthermore, setWorkloadBroadcast is never called in the test setup, so onWorkloadBroadcast is null and no broadcast fires at all. This test proves nothing about the fallback path's broadcast behavior. [fixable]

(app as any)._workloadBroadcast = (msg: unknown) => broadcasts.push(msg);

await request(app)
.post('/api/workload/items/telos-no-broadcast/promote')
.set('Cookie', authCookie)
.send({ title: 'No broadcast test' });

const workloadBroadcasts = broadcasts.filter((b: any) => b.type === 'workload_item_updated');
expect(workloadBroadcasts).toHaveLength(0);

(app as any)._workloadBroadcast = origBroadcast;
});

it('POST /api/workload/items/:id/promote — broadcasts workload_item_updated', async () => {
// Create an item first
const signal = {
Expand Down
24 changes: 24 additions & 0 deletions server/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,30 @@ export const WorkloadItemUpdateBody = z.object({

export const WorkloadPromoteBody = z.object({
description: z.string().optional(),
title: z.string().optional(),
contextHints: z
.object({
repos: z.array(z.string()).optional(),
paths: z.array(z.string()).optional(),
issues: z.array(z.string()).optional(),
docIds: z.array(z.string()).optional(),
people: z.array(z.string()).optional(),
jiraKeys: z.array(z.string()).optional(),
keywords: z.array(z.string()).optional(),
taskHint: z.string().optional(),
})
.optional(),
sources: z
.array(
z.object({
type: z.string(),
url: z.string(),
title: z.string(),
author: z.string().optional(),
snippet: z.string().optional(),
}),
)
.optional(),
});

// -- Session creation schemas --
Expand Down
Loading
Loading