Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 4 additions & 2 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,7 +1508,7 @@ describe("retained v2 feed presentation", () => {
expect(afterPresentation[1]).toBe(beforePresentation[1]);
});

it.each(["running", "completed", "interrupted"] as const)(
it.each(["running", "completed", "failed", "interrupted", "cancelled"] as const)(
"uses the compaction row as the live activity only while %s",
(status) => {
const compact = projected(
Expand Down Expand Up @@ -1544,7 +1544,9 @@ describe("retained v2 feed presentation", () => {
? "Compacting context"
: status === "completed"
? "Context compacted 899K → 19K tokens"
: "Context compacted",
: status === "failed"
? "Context compaction failed"
: "Context compaction stopped",
},
],
});
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,14 @@ export function useThreadComposerState() {
if (queuedCompact) return true;
const activeRunId = selectedThreadRuntime?.activeRunId;
if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false;
if (
selectedThreadVisibleTurnItems.some(
({ item }) =>
item.runId === activeRunId && item.type === "compaction" && item.status === "running",
)
) {
return true;
}
const compactMessage = selectedThreadVisibleTurnItems.findLast(
({ item }) =>
item.runId === activeRunId &&
Expand All @@ -386,7 +394,10 @@ export function useThreadComposerState() {
({ item }) =>
item.runId === activeRunId &&
item.type === "compaction" &&
(item.status === "completed" || item.status === "failed"),
(item.status === "completed" ||
item.status === "failed" ||
item.status === "interrupted" ||
item.status === "cancelled"),
);
}, [
dispatchingQueuedMessageId,
Expand Down
173 changes: 173 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2819,6 +2819,179 @@ describe("ClaudeAdapterV2 background wake turns", () => {
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),
);

it.effect("tracks automatic compaction before its boundary and deduplicates status updates", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("claude-auto-compact-attempt"),
text: "Continue working.",
attachments: [],
}),
);
for (const index of [1, 2]) {
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "status",
status: "compacting",
uuid: `00000000-0000-4000-8000-00000000021${index}`,
session_id: WAKE_NATIVE_SESSION,
}),
);
}
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "compact_boundary",
compact_metadata: { trigger: "auto", pre_tokens: 1500, post_tokens: 400 },
uuid: "00000000-0000-4000-8000-000000000213",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000214",
result: "Work complete.",
}),
);
yield* Queue.take(harness.terminalReceipts);
const updates = harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "compaction"
? [event.turnItem]
: [],
);
assert.lengthOf(updates, 2);
const [running, completed] = updates;
assert.equal(running?.status, "running");
assert.isNull(running?.completedAt);
assert.equal(completed?.status, "completed");
assert.equal(completed?.id, running?.id);
assert.equal(completed?.ordinal, running?.ordinal);
assert.deepEqual(completed?.startedAt, running?.startedAt);
assert.equal(completed?.beforeTokenCount, 1500);
assert.equal(completed?.afterTokenCount, 400);
assert.isNotNull(completed?.completedAt);
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),
);

it.effect.each(["completed", "failed", "interrupted", "stop", "query_exit"] as const)(
"settles an unfinished compaction on %s and ignores idle status frames",
(outcome) =>
Effect.gen(function* () {
const harness = yield* makeWakeHarnessWithOptions({
close: (sdkMessages) => Queue.shutdown(sdkMessages),
});
const attemptId = RunAttemptId.make(`claude-compact-${outcome}`);
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId,
text: "Continue.",
attachments: [],
}),
);
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "status",
status: "compacting",
uuid: "00000000-0000-4000-8000-000000000221",
session_id: WAKE_NATIVE_SESSION,
}),
);
if (outcome === "query_exit") {
yield* Queue.shutdown(harness.sdkMessages);
} else if (outcome === "stop") {
const idAllocator = yield* IdAllocator.IdAllocatorV2;
yield* harness.runtime.interruptTurn({
providerThread: harness.providerThread,
providerTurnId: idAllocator.derive.providerTurn({
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
nativeTurnId: `turn:${attemptId}`,
}),
});
} else {
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000222",
result: "Stopped before the boundary.",
...(outcome === "failed" ? { isError: true, terminalReason: "api_error" } : {}),
...(outcome === "interrupted" ? { terminalReason: "aborted_streaming" } : {}),
}),
);
}
const terminal = yield* Queue.take(harness.terminalReceipts);
assert.equal(
terminal.status,
outcome === "stop" ? "interrupted" : outcome === "query_exit" ? "failed" : outcome,
);
const updates = harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "compaction"
? [event.turnItem]
: [],
);
assert.lengthOf(updates, 2);
assert.equal(updates[0]?.status, "running");
assert.equal(updates[1]?.id, updates[0]?.id);
assert.equal(
updates[1]?.status,
terminal.status === "completed" ? "failed" : terminal.status,
);
assert.isNotNull(updates[1]?.completedAt);
if (outcome !== "query_exit" && outcome !== "stop") {
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "status",
status: "compacting",
uuid: "00000000-0000-4000-8000-000000000223",
session_id: WAKE_NATIVE_SESSION,
}),
);
assert.equal(
harness.events.filter(
(event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
).length,
2,
);
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make(`claude-after-compact-${outcome}`),
text: "Next normal turn.",
attachments: [],
providerTurnOrdinal: 2,
}),
);
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000224",
result: "Next turn complete.",
}),
);
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed");
assert.equal(
harness.events.filter(
(event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
).length,
2,
);
}
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),
);

it.effect("titles Claude reads, searches, and skills on tool completion", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
Expand Down
65 changes: 63 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2679,6 +2679,7 @@ interface ActiveClaudeTurnContext {
>;
};
readonly toolCalls: Map<string, ActiveClaudeToolCall>;
compaction: Extract<OrchestrationV2TurnItem, { type: "compaction" }> | null;
readonly ignoredTaskIds: Set<string>;
readonly announcedUsageLimits: Set<string>;
authenticationFailureMessage: string | undefined;
Expand Down Expand Up @@ -4801,6 +4802,23 @@ export function makeClaudeAdapterV2(
readonly result?: SDKResultMessage;
}) {
yield* reasoningDeltas.flushTurn(input.context.nativeTurnId);
if (input.context.compaction !== null) {
yield* emitProviderEvent({
type: "turn_item.updated",
driver: CLAUDE_PROVIDER,
turnItem: {
...input.context.compaction,
status: input.status === "completed" ? "failed" : input.status,
title:
input.status === "completed" || input.status === "failed"
? "Context compaction failed"
: "Context compaction stopped",
completedAt: input.completedAt,
updatedAt: input.completedAt,
},
});
input.context.compaction = null;
}
for (const toolCall of input.context.toolCalls.values()) {
const artifacts = buildToolCallArtifacts({
context: input.context,
Expand Down Expand Up @@ -5556,6 +5574,47 @@ export function makeClaudeAdapterV2(
return;
}

if (
message.type === "system" &&
message.subtype === "status" &&
message.status === "compacting"
) {
if (context.compaction !== null) return;
const now = yield* DateTime.now;
const nativeItemId = message.uuid;
context.compaction = {
id: idAllocator.derive.turnItemFromProviderItem({
driver: CLAUDE_PROVIDER,
nativeItemId,
}),
threadId: context.input.threadId,
runId: context.input.runId,
nodeId: context.input.rootNodeId,
providerThreadId: context.input.providerThread.id,
providerTurnId: context.providerTurnId,
nativeItemRef: {
driver: CLAUDE_PROVIDER,
nativeId: nativeItemId,
strength: "strong",
},
parentItemId: null,
ordinal: yield* resolveItemOrdinal(context, nativeItemId),
type: "compaction",
driver: CLAUDE_PROVIDER,
status: "running",
title: "Compacting context",
startedAt: now,
completedAt: null,
updatedAt: now,
};
yield* emitProviderEvent({
type: "turn_item.updated",
driver: CLAUDE_PROVIDER,
turnItem: context.compaction,
});
return;
}

// Subagent narration belongs to its child thread, never the parent log.
if (message.type === "stream_event" && !message.parent_tool_use_id) {
const event = message.event;
Expand Down Expand Up @@ -5642,7 +5701,7 @@ export function makeClaudeAdapterV2(

if (message.type === "system" && message.subtype === "compact_boundary") {
const now = yield* DateTime.now;
const nativeItemId = message.uuid;
const nativeItemId = context.compaction?.nativeItemRef?.nativeId ?? message.uuid;
const postTokens = message.compact_metadata.post_tokens;
const afterTokenCount =
typeof postTokens === "number" && Number.isFinite(postTokens) && postTokens > 0
Expand Down Expand Up @@ -5699,7 +5758,7 @@ export function makeClaudeAdapterV2(
driver: CLAUDE_PROVIDER,
status: "completed",
title: "Context compacted",
startedAt: now,
startedAt: context.compaction?.startedAt ?? now,
completedAt: now,
updatedAt: now,
...(message.compact_metadata.pre_tokens === undefined
Expand All @@ -5708,6 +5767,7 @@ export function makeClaudeAdapterV2(
...(afterTokenCount === undefined ? {} : { afterTokenCount }),
},
});
context.compaction = null;
return;
}

Expand Down Expand Up @@ -7147,6 +7207,7 @@ export function makeClaudeAdapterV2(
blocks: new Map(),
},
toolCalls: new Map(),
compaction: null,
ignoredTaskIds: new Set(),
announcedUsageLimits: new Set(),
authenticationFailureMessage: undefined,
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3513,12 +3513,21 @@ export default function ChatView(props: ChatViewProps) {
({ item }) =>
item.type === "compaction" &&
item.runId === activeActivityRun?.runId &&
item.status === "completed",
(item.status === "completed" ||
item.status === "failed" ||
item.status === "interrupted" ||
item.status === "cancelled"),
);
const compactionRunning = serverVisibleTurnItems.some(
({ item }) =>
item.type === "compaction" &&
item.runId !== null &&
item.runId === activeRuntime?.activeRunId &&
item.status === "running",
);
const isCompacting =
(isSendBusy || phase === "connecting" || phase === "running") &&
compactRequestIsActive &&
!compactionSettled;
(compactionRunning || (compactRequestIsActive && !compactionSettled));
// A rewind is not agent work: the composer shows "Rewinding conversation"
// instead of the timeline growing a Thinking row.
const isWorking =
Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/work-log/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export function contextCompactionLabel(
"status" | "beforeTokenCount" | "afterTokenCount"
>,
): string {
if (item.status === "failed") return "Context compaction failed";
if (item.status === "cancelled" || item.status === "interrupted")
return "Context compaction stopped";
if (item.status === "running") return "Compacting context";
if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) {
return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`;
Expand Down
Loading