diff --git a/frontend/src/components/TokenBar.tsx b/frontend/src/components/TokenBar.tsx
index b18353fa..b3b6068c 100644
--- a/frontend/src/components/TokenBar.tsx
+++ b/frontend/src/components/TokenBar.tsx
@@ -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 (
+
+ COMPACTING
+
+ );
+ }
+
return (
<>
= {}): TokenState {
numTurns: 0,
turnIndex: 0,
numCompactions: 0,
+ compacting: false,
...overrides,
};
}
@@ -104,6 +105,16 @@ describe('TokenBar', () => {
expect(screen.getByText(/Session tokens/)).toBeTruthy();
});
+ it('shows compacting indicator when compacting is true', () => {
+ const { container } = render(
+ ,
+ );
+ 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(
{
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 ────────────────────────────────────────────────────────────────────
diff --git a/packages/client/src/protocol-parser.ts b/packages/client/src/protocol-parser.ts
index 5fab88e6..6d927fc3 100644
--- a/packages/client/src/protocol-parser.ts
+++ b/packages/client/src/protocol-parser.ts
@@ -522,6 +522,10 @@ export function parseServerMessage(
break;
}
+ case 'compaction_status':
+ result.tokensUpdate = { compacting: Boolean(msg.active) };
+ break;
+
// Progress tracking messages
case 'progress_start':
result.progressUpdate = {
diff --git a/packages/client/src/slices/tokens.ts b/packages/client/src/slices/tokens.ts
index 15570d88..a981becf 100644
--- a/packages/client/src/slices/tokens.ts
+++ b/packages/client/src/slices/tokens.ts
@@ -5,6 +5,7 @@ export interface TokensState {
numTurns: number;
turnIndex: number;
numCompactions: number;
+ compacting: boolean;
}
export const DEFAULT_CONTEXT_CEILING = 200_000;
@@ -16,4 +17,5 @@ export const INITIAL_TOKENS_STATE: TokensState = {
numTurns: 0,
turnIndex: 0,
numCompactions: 0,
+ compacting: false,
};
diff --git a/server/__tests__/token-update.test.ts b/server/__tests__/token-update.test.ts
index 6e9ad8ab..5240b246 100644
--- a/server/__tests__/token-update.test.ts
+++ b/server/__tests__/token-update.test.ts
@@ -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',
@@ -429,6 +447,59 @@ describe('token_update emission', () => {
// The final token_update should include numCompactions: 1
const last = tokenUpdates[tokenUpdates.length - 1];
expect(last).toMatchObject({ numCompactions: 1 });
+
+ // 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[] = [
+ {
+ 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 () => {
diff --git a/server/__tests__/workload-routes.test.ts b/server/__tests__/workload-routes.test.ts
index 00ac61bd..13caa804 100644
--- a/server/__tests__/workload-routes.test.ts
+++ b/server/__tests__/workload-routes.test.ts
@@ -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 () => {
const res = await request(app)
.post('/api/workload/items/nonexistent-id/promote')
.set('Cookie', authCookie)
@@ -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 () => {
+ const broadcasts: unknown[] = [];
+ const origBroadcast = (app as any)._workloadBroadcast;
+ (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 = {
diff --git a/server/api-schemas.ts b/server/api-schemas.ts
index 5567ab34..6c51f601 100644
--- a/server/api-schemas.ts
+++ b/server/api-schemas.ts
@@ -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 --
diff --git a/server/app.ts b/server/app.ts
index dbec3a6c..87984fbd 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -1916,34 +1916,51 @@ app.post('/api/workload/items/:id/promote', (req, res) => {
}
const item = workloadStore.get(req.params.id);
- if (!item) {
- res.status(404).json({ error: 'Item not found' });
+
+ // Resolve title and context from workloadStore item or fallback body data (Telos items)
+ const title = item?.title ?? body.data.title;
+ if (!title) {
+ res.status(404).json({ error: 'Item not found and no title provided' });
return;
}
+ const hints = item?.contextHints ?? body.data.contextHints;
+ const taskHint = hints?.taskHint ?? undefined;
+
// Build description from item context
const descParts: string[] = [];
if (body.data.description) descParts.push(body.data.description);
- if (item.contextHints.taskHint) descParts.push(item.contextHints.taskHint);
- const hintsWithValues = Object.entries(item.contextHints)
- .filter(([k, v]) => k !== 'taskHint' && Array.isArray(v) && v.length > 0)
- .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`);
- if (hintsWithValues.length > 0) descParts.push(hintsWithValues.join('\n'));
+ if (taskHint) descParts.push(taskHint);
+ if (hints) {
+ const hintsWithValues = Object.entries(hints)
+ .filter(([k, v]) => k !== 'taskHint' && Array.isArray(v) && v.length > 0)
+ .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`);
+ if (hintsWithValues.length > 0) descParts.push(hintsWithValues.join('\n'));
+ }
+
+ // Build annotations from sources
+ const annotations: string[] = item
+ ? item.sources.map((s) => `Source: [${s.sourceType}] ${s.title} — ${s.url}`)
+ : (body.data.sources ?? []).map((s) => `Source: [${s.type}] ${s.title} — ${s.url}`);
// Create root task (goal) from item
const task = taskStore.create({
- title: item.title,
+ title,
description: descParts.join('\n\n') || undefined,
- annotations: item.sources.map((s) => `Source: [${s.sourceType}] ${s.title} — ${s.url}`),
+ annotations,
});
- // Link item to goal
- workloadStore.setGoalId(item.id, task.id);
+ // Link item to goal only if it exists in workloadStore
+ if (item) {
+ workloadStore.setGoalId(item.id, task.id);
+ }
- const updatedItem = workloadStore.get(item.id);
- res.status(201).json({ task, item: updatedItem });
+ const updatedItem = item ? workloadStore.get(item.id) : undefined;
+ res.status(201).json(updatedItem ? { task, item: updatedItem } : { task });
onTaskBroadcast?.({ type: 'task_state', tasks: taskStore.getTree() });
- onWorkloadBroadcast?.({ type: 'workload_item_updated', item: updatedItem });
+ if (updatedItem) {
+ onWorkloadBroadcast?.({ type: 'workload_item_updated', item: updatedItem });
+ }
});
// --- Static files ---
diff --git a/server/query-loop.ts b/server/query-loop.ts
index 4fc0e953..721f9933 100644
--- a/server/query-loop.ts
+++ b/server/query-loop.ts
@@ -278,6 +278,7 @@ async function _runQueryLoopInner(
let agentContextTokens = 0; // full context window size (input + cached) from parent message_start
let turnIndex = 0; // increments on each parent message_start (excludes sub-agents)
let numCompactions = 0; // counts successful compaction events from SDK
+ let compacting = false; // true while SDK is actively compacting context
let liveSessionTokens = 0; // cumulative total across all API calls in this query
let cumulativeOutputTokens = 0; // accumulated output tokens (fresh per API call)
const sessionStartedAt = Date.now(); // wall-clock start for fallback duration
@@ -569,6 +570,16 @@ async function _runQueryLoopInner(
currentSession.cumulativeSessionTokens = Math.max(sdkTokens, liveSessionTokens);
currentSession.cumulativeCostUsd = usageData.totalCostUsd;
+ // Safety: if compaction was in progress but never completed (SDK crash,
+ // abort, etc.), reset the flag so the frontend doesn't stay stuck.
+ if (compacting) {
+ compacting = false;
+ log.warn('compaction flag reset on result — success signal was never received', {
+ clientId,
+ });
+ emit({ type: 'compaction_status', active: false });
+ }
+
emit({
type: 'token_update',
agentContext: agentContextTokens,
@@ -956,6 +967,14 @@ async function _runQueryLoopInner(
blockType: 'text',
}),
);
+ } else if (blockType === 'compaction') {
+ // SDK is compacting context — signal the frontend immediately.
+ // No snapshot block is pushed; the generic blockIdByIndex registration
+ // above is harmless — content_block_stop finds no snapshot block and
+ // silently skips, while openBlockCount stays balanced (++ here, -- there).
+ compacting = true;
+ log.info('compaction started', { clientId });
+ emit({ type: 'compaction_status', active: true });
}
} else if (evt?.type === 'content_block_delta') {
const parentToolUseId = msg.parent_tool_use_id as string | undefined;
@@ -1033,6 +1052,7 @@ async function _runQueryLoopInner(
const entry = toolInputBuffers.get(index);
if (entry) entry.inputBuf += delta.partial_json as string;
}
+ // compaction_delta — intentionally ignored (SDK-internal summary content)
} else if (evt?.type === 'content_block_stop') {
const parentToolUseId = msg.parent_tool_use_id as string | undefined;
const subagent = parentToolUseId ? activeSubagents.get(parentToolUseId) : undefined;
@@ -1208,7 +1228,9 @@ async function _runQueryLoopInner(
const compactResult = (msg as Record).compact_result;
if (subtype === 'status' && compactResult === 'success') {
numCompactions++;
+ compacting = false;
log.info('compaction completed', { clientId, numCompactions });
+ emit({ type: 'compaction_status', active: false });
}
// Track subagent task lifecycle for interrupt cancellation