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

it.each([
{
outcome: "completed",
message: "Prime Agent finished without sending a final response.",
tone: "info" as const,
status: null,
},
{
outcome: "failed",
message: "Prime Agent stopped before sending a final response.",
tone: "error" as const,
status: null,
},
])("keeps the $outcome missing-response row outside settled-turn folding", (fixture) => {
const turnId = TurnId.make(`turn-${fixture.outcome}`);
const thread = makeThread({
id: ThreadId.make(`thread-${fixture.outcome}`),
projectId: ProjectId.make("project-1"),
title: "Missing final response",
latestTurn: {
turnId,
state: fixture.outcome === "completed" ? "completed" : "error",
requestedAt: "2026-04-01T00:00:00.000Z",
startedAt: "2026-04-01T00:00:01.000Z",
completedAt: "2026-04-01T00:00:18.000Z",
assistantMessageId: null,
},
activities: [
makeActivity({
id: EventId.make(`tool-${fixture.outcome}`),
kind: "tool.completed",
tone: "tool",
summary: "Read files",
createdAt: "2026-04-01T00:00:05.000Z",
turnId,
payload: { itemType: "file_read", status: "completed" },
}),
makeActivity({
id: EventId.make(`missing-response-${fixture.outcome}`),
kind: "turn.response.missing",
tone: fixture.tone,
summary: fixture.message,
createdAt: "2026-04-01T00:00:18.000Z",
turnId,
payload: { outcome: fixture.outcome },
}),
],
});

const feed = buildThreadFeed(thread);
const presented = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
expect(presented.map((entry) => entry.id)).toEqual([
`turn-fold:${turnId}`,
`missing-response-${fixture.outcome}`,
]);
expect(presented[1]).toMatchObject({
type: "activity-group",
activities: [
{
summary: fixture.message,
status: fixture.status,
toolLike: false,
terminalResponseNotice: true,
},
],
});
});

it("folds ordinary runtime activity instead of treating it as a terminal response notice", () => {
const turnId = TurnId.make("turn-runtime-warning");
const thread = makeThread({
id: ThreadId.make("thread-runtime-warning"),
projectId: ProjectId.make("project-1"),
title: "Runtime warning",
latestTurn: {
turnId,
state: "completed",
requestedAt: "2026-04-01T00:00:00.000Z",
startedAt: "2026-04-01T00:00:01.000Z",
completedAt: "2026-04-01T00:00:18.000Z",
assistantMessageId: null,
},
activities: [
makeActivity({
id: EventId.make("ordinary-runtime-warning"),
kind: "runtime.warning",
summary: "Reconnecting",
createdAt: "2026-04-01T00:00:18.000Z",
turnId,
payload: { message: "Reconnecting" },
}),
],
});

const feed = buildThreadFeed(thread);
expect(feed[0]).not.toMatchObject({
activities: [{ terminalResponseNotice: true }],
});
expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual([
expect.objectContaining({ type: "turn-fold", turnId }),
]);
});

it("measures a steer-superseded turn from its user boundary through trailing work", () => {
const firstTurnId = TurnId.make("turn-1");
const secondTurnId = TurnId.make("turn-2");
Expand Down
31 changes: 29 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ThreadFeedActivity {
| "zap";
readonly toolLike: boolean;
readonly status: "success" | "failure" | "neutral" | null;
/** Terminal response notice that must remain outside settled-turn work folding. */
readonly terminalResponseNotice?: boolean;
}

const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
Expand Down Expand Up @@ -561,7 +563,8 @@ function normalizeCompactToolLabel(value: string): string {
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
}

function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
function workLogEntryIsToolLike(entry: WorkLogEntry | DerivedWorkLogEntry): boolean {
if ("activityKind" in entry && entry.activityKind === "turn.response.missing") return false;
if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
return true;
}
Expand Down Expand Up @@ -1101,6 +1104,18 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
continue;
}

if (entry.activity.terminalResponseNotice === true) {
grouped.push({
type: "activity-group",
id: entry.id,
createdAt: entry.createdAt,
turnId: entry.turnId,
activities: [entry.activity],
});
openGroupActivities = null;
continue;
}

if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
openGroupActivities.push(entry.activity);
continue;
Expand Down Expand Up @@ -1210,7 +1225,16 @@ function deriveThreadFeedTurnFolds(

const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);
const hiddenEntryIds = new Set(
entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id),
entries
.filter(
(entry) =>
entry.id !== terminalAssistantMessageId &&
!(
entry.type === "activity-group" &&
entry.activities.some((activity) => activity.terminalResponseNotice === true)
),
)
.map((entry) => entry.id),
);
if (hiddenEntryIds.size === 0) {
continue;
Expand Down Expand Up @@ -1585,6 +1609,9 @@ export function buildThreadFeed(
icon: workEntryIcon(entry),
toolLike: workLogEntryIsToolLike(entry),
status: workEntryStatus(entry),
...(entry.activityKind === "turn.response.missing"
? { terminalResponseNotice: true }
: {}),
},
};
}),
Expand Down
17 changes: 10 additions & 7 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
const omitInterleavedFinalText = process.env.T3_ACP_OMIT_INTERLEAVED_FINAL_TEXT === "1";
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
Expand Down Expand Up @@ -620,13 +621,15 @@ const program = Effect.gen(function* () {
},
});

yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "after tool" },
},
});
if (!omitInterleavedFinalText) {
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "after tool" },
},
});
}

return { stopReason: "end_turn" };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,10 @@ describe("CheckpointReactor", () => {
(entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1,
);
expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
expect(
(thread.checkpoints[0] as { readonly assistantMessageId: string | null } | undefined)
?.assistantMessageId,
).toBeNull();
expect(
gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
).toBe(true);
Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,7 @@ const make = Effect.gen(function* () {
input.assistantMessageId ??
input.thread.messages
.toReversed()
.find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id ??
MessageId.make(`assistant:${input.turnId}`);
.find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id;

yield* orchestrationEngine.dispatch({
type: "thread.turn.diff.complete",
Expand All @@ -323,7 +322,7 @@ const make = Effect.gen(function* () {
checkpointRef: targetCheckpointRef,
status: input.status,
files,
assistantMessageId,
...(assistantMessageId === undefined ? {} : { assistantMessageId }),
checkpointTurnCount: input.turnCount,
createdAt: input.createdAt,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,125 @@ const base = {
threadId: ThreadId.make("thread-1"),
};

describe("runtimeEventToActivities missing final response", () => {
it.each([
{
type: "runtime.warning" as const,
provider: ProviderDriverKind.make("primeAgent"),
outcome: "completed" as const,
message: "Prime Agent finished without sending a final response.",
tone: "info" as const,
},
{
type: "runtime.error" as const,
provider: ProviderDriverKind.make("primeAgent"),
outcome: "failed" as const,
message: "Prime Agent stopped before sending a final response.",
tone: "error" as const,
},
])("projects the $outcome marker without provider detail", (fixture) => {
const [activity] = runtimeEventToActivities({
...base,
provider: fixture.provider,
type: fixture.type,
eventId: EventId.make(`missing-response-${fixture.outcome}`),
turnId: TurnId.make(`turn-${fixture.outcome}`),
payload: {
message: fixture.message,
detail: { kind: "missing-final-response", outcome: fixture.outcome },
},
} satisfies ProviderRuntimeEvent);

expect(activity).toMatchObject({
kind: "turn.response.missing",
summary: fixture.message,
tone: fixture.tone,
payload: { outcome: fixture.outcome },
turnId: `turn-${fixture.outcome}`,
});
expect(activity?.payload).toEqual({ outcome: fixture.outcome });
expect(JSON.stringify(activity)).not.toContain("missing-final-response");
});

it.each([
{
name: "an extra field",
type: "runtime.warning" as const,
outcome: "completed" as const,
detail: { kind: "missing-final-response", outcome: "completed", private: "raw" },
},
{
name: "a mismatched outcome",
type: "runtime.warning" as const,
outcome: "failed" as const,
detail: { kind: "missing-final-response", outcome: "failed" },
},
{
name: "a non-object detail",
type: "runtime.error" as const,
outcome: "failed" as const,
detail: "missing-final-response",
},
])("leaves $type unchanged when its marker has $name", (fixture) => {
const [activity] = runtimeEventToActivities({
...base,
type: fixture.type,
eventId: EventId.make(`malformed-${fixture.outcome}`),
payload: {
message: "Ordinary provider event",
detail: fixture.detail,
},
} satisfies ProviderRuntimeEvent);

expect(activity?.kind).toBe(fixture.type);
});

it("does not reclassify another provider using the same detail shape", () => {
const [activity] = runtimeEventToActivities({
...base,
type: "runtime.error",
eventId: EventId.make("other-provider-missing-marker"),
payload: {
message: "Codex provider error",
detail: { kind: "missing-final-response", outcome: "failed" },
},
} satisfies ProviderRuntimeEvent);

expect(activity).toMatchObject({
kind: "runtime.error",
summary: "Runtime error",
payload: { message: "Codex provider error" },
});
});

it("preserves ordinary warning and error activity presentations", () => {
const [warning] = runtimeEventToActivities({
...base,
type: "runtime.warning",
eventId: EventId.make("ordinary-warning"),
payload: { message: "Reconnecting", detail: { willRetry: true } },
} satisfies ProviderRuntimeEvent);
const [error] = runtimeEventToActivities({
...base,
type: "runtime.error",
eventId: EventId.make("ordinary-error"),
payload: { message: "Provider failed", detail: { private: "unchanged-drop" } },
} satisfies ProviderRuntimeEvent);

expect(warning).toMatchObject({
kind: "runtime.warning",
summary: "Reconnecting",
payload: { message: "Reconnecting", detail: { willRetry: true } },
});
expect(error).toMatchObject({
kind: "runtime.error",
summary: "Runtime error",
payload: { message: "Provider failed" },
});
expect(error?.payload).toEqual({ message: "Provider failed" });
});
});

describe("runtimeEventToActivities task progress", () => {
it("persists usage independently from replaceable activity", () => {
const taskId = RuntimeTaskId.make("agent-1");
Expand Down
Loading
Loading