Skip to content
Closed
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
66 changes: 66 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,72 @@ describe("buildThreadFeed", () => {
]);
});

it.each(["commentary", undefined] as const)(
"keeps confirmed final answers visible across continuations ending with %s",
(trailingPhase) => {
const reply = (id: string, phase?: "final_answer" | "commentary") => ({
...assistantMessage(),
id: TurnItemId.make(id),
messageId: MessageId.make(id),
...(phase === undefined ? {} : { phase }),
});
const items: OrchestrationV2TurnItem[] = [
userMessage(),
command(),
reply("first-answer", "final_answer"),
{
...base("wake", "2026-06-20T00:00:04.000Z", 4),
type: "notification",
source: { kind: "system" },
outcome: "completed",
summary: "Delegated task finished",
},
reply("progress", "commentary"),
{
...userMessage(),
id: TurnItemId.make("steer"),
messageId: MessageId.make("steer"),
inputIntent: "steer",
},
reply("second-answer", "final_answer"),
reply("trailing", trailingPhase),
];
const present = (turnItems: typeof items, expanded = false) =>
deriveThreadFeedPresentation(
buildThreadFeed(turnItems.map((item, position) => projected(item, position))),
null,
expanded ? new Set([runId]) : new Set(),
);
const assistantIds = (entries: ThreadFeedEntry[]) =>
entries.flatMap((entry) =>
entry.type === "message" && entry.message.role === "assistant" ? [entry.id] : [],
);
expect(assistantIds(present(items.slice(0, 3)))).toEqual(["first-answer"]);
expect(assistantIds(present(items))).toEqual([
"first-answer",
"second-answer",
...(trailingPhase === undefined ? ["trailing"] : []),
]);
expect(
assistantIds(
present(
items.map((item) =>
item.id === "first-answer"
? { ...item, status: "interrupted", completedAt: null }
: item,
),
),
),
).not.toContain("first-answer");
expect(assistantIds(present(items, true))).toEqual([
"first-answer",
"progress",
"second-answer",
"trailing",
]);
},
);

// regression: #21393 show plans and fold settled progress notes
it("folds progress even when a response has no tool calls", () => {
const feed = buildThreadFeed([
Expand Down
19 changes: 18 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,7 +1152,13 @@ function deriveThreadFeedRunFolds(
group.entries
.filter(
(entry) =>
entry.id !== terminalAssistantId &&
!(
entry.type === "message" &&
(messageIsConfirmedFinalAnswer(entry.message) ||
(entry.id === terminalAssistantId &&
(entry.message.projectedItem?.item.type !== "assistant_message" ||
entry.message.projectedItem.item.phase === undefined)))
) &&
entry.type !== "html-render" &&
entry.type !== "mcp-app" &&
!(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) &&
Expand Down Expand Up @@ -1244,6 +1250,17 @@ function deriveThreadFeedRunFolds(
return foldsByAnchorId;
}

function messageIsConfirmedFinalAnswer(message: ThreadFeedMessage): boolean {
const item = message.projectedItem?.item;
return (
item?.type === "assistant_message" &&
item.phase === "final_answer" &&
item.status === "completed" &&
!item.streaming &&
!message.streaming
);
}

const supersededReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedActivityGroup>();
const trailingReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedActivityGroup>();

Expand Down
147 changes: 144 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6470,15 +6470,25 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

const usageStreamFrame = (event: unknown, parentToolUseId: string | null = null) =>
const usageStreamFrame = (
event: unknown,
parentToolUseId: string | null = null,
apiMessageId?: string,
) =>
claudeSdkFrame({
type: "stream_event",
event,
parent_tool_use_id: parentToolUseId,
session_id: WAKE_NATIVE_SESSION,
uuid: "usage-stream-frame",
...(apiMessageId === undefined ? {} : { api_message_id: apiMessageId }),
});
const usageSnapshotFrame = (id: string, uuid: string, usage: unknown) =>
const usageSnapshotFrame = (
id: string,
uuid: string,
usage: unknown,
stopReason: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" | null = null,
) =>
claudeSdkFrame({
type: "assistant",
message: {
Expand All @@ -6487,7 +6497,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
type: "message",
role: "assistant",
content: [{ type: "text", text: "Working." }],
stop_reason: null,
stop_reason: stopReason,
stop_sequence: null,
usage,
},
Expand All @@ -6508,6 +6518,55 @@ describe("ClaudeAdapterV2 background wake turns", () => {
: [],
);

it.effect.each([
{ reason: "end_turn", phase: "final_answer" },
{ reason: "tool_use", phase: "commentary" },
{ reason: "max_tokens", phase: undefined },
{ reason: "stop_sequence", phase: undefined },
{ reason: null, phase: undefined },
{ reason: "end_turn", phase: "final_answer", resultOnly: true },
] as const)(
"preserves only confirmed assistant intent from snapshot stop reason $reason",
(input) =>
Effect.gen(function* () {
const { reason, phase } = input;
const resultOnly = "resultOnly" in input;
const harness = yield* makeWakeHarness;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-answer-phase"),
text: "Continue.",
attachments: [],
}),
);
if (!resultOnly) {
yield* Queue.offer(
harness.sdkMessages,
usageSnapshotFrame("msg_answer_phase", "answer-phase", zeroUsage, reason),
);
}
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({ uuid: "result-answer-phase", result: "Done." }),
);
yield* Queue.take(harness.terminalReceipts);
const items = harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
? [event.turnItem]
: [],
);
assert.equal(items.at(-1)?.phase, phase);
assert.equal(items.at(-1)?.text, resultOnly ? "Done." : "Working.");
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("reports context from final stream usage when the request starts at zero", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
Expand Down Expand Up @@ -6559,13 +6618,95 @@ describe("ClaudeAdapterV2 background wake turns", () => {
const terminal = yield* Queue.take(harness.terminalReceipts);
assert.equal(terminal.status, "completed");
assert.deepEqual(reportedContextTokens(harness.events), [38_123]);
const assistantItems = new Map(
harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
? [[event.turnItem.nativeItemRef?.nativeId, event.turnItem.phase] as const]
: [],
),
);
assert.deepEqual([...assistantItems.values()], ["commentary", "commentary", "final_answer"]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect(
"matches delayed stop metadata to its native message across blocks and continuations",
() =>
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("attempt-delayed-answer-phase"),
text: "Continue.",
attachments: [],
}),
);
const frames = [
usageStreamFrame({ type: "message_start", message: { id: "msg_answer" } }),
usageSnapshotFrame("msg_answer", "answer-block-1", zeroUsage),
usageSnapshotFrame("msg_answer", "answer-block-2", zeroUsage),
usageStreamFrame({ type: "message_start", message: { id: "msg_progress" } }),
usageSnapshotFrame("msg_progress", "progress-block", zeroUsage),
usageStreamFrame(
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
null,
"msg_answer",
),
usageStreamFrame(
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
"child-tool",
"msg_progress",
),
usageStreamFrame(
{ type: "message_delta", delta: { stop_reason: "tool_use" } },
null,
"msg_progress",
),
usageSnapshotFrame("msg_answer", "answer-block-3", zeroUsage),
makeResultFrame({ uuid: "result-delayed-answer-phase", result: "Done." }),
];
for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
yield* Queue.take(harness.terminalReceipts);
const phases = new Map(
harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
? [[event.turnItem.nativeItemRef?.nativeId, event.turnItem.phase] as const]
: [],
),
);
assert.deepEqual(
[...phases],
[
["answer-block-1", "final_answer"],
["answer-block-2", "final_answer"],
["progress-block", "commentary"],
["answer-block-3", "final_answer"],
],
);
assert.equal(
harness.events.some(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "assistant_message" &&
event.turnItem.nativeItemRef?.nativeId === "progress-block" &&
event.turnItem.phase === "final_answer",
),
false,
);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("merges cumulative stream usage into the message_start counts", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarnessWithOptions({
Expand Down
Loading
Loading