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
47 changes: 47 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4053,3 +4053,50 @@ it("keeps attachment-only question answers expandable outside mobile work groups
expect(running[1]).toBe(group);
expect(running[2]?.type).toBe("work-toggle");
});

describe("orphaned background tasks never become subagent cards", () => {
// The next provider process settles a watcher it never launched, reporting
// only the task id and status. Ingestion stamps that bare row "agent", and
// judging it alone turned shell watchers into phantom subagents.
it("keeps a settled shell watcher classified as background work", () => {
const thread = makeThread({
id: ThreadId.make("thread-orphan"),
projectId: ProjectId.make("project-orphan"),
title: "Orphan proof",
activities: [
makeActivity({
id: EventId.make("watcher-start"),
kind: "task.started",
summary: "local_bash task started",
createdAt: "2026-09-24T21:16:13.000Z",
payload: {
taskId: "watcher-1",
taskType: "local_bash",
agentKind: "background",
title: "Run the parallel merge queue dispatcher",
},
}),
makeActivity({
id: EventId.make("watcher-stop"),
kind: "task.completed",
summary: "Task stopped",
createdAt: "2026-09-24T21:34:03.000Z",
payload: {
taskId: "watcher-1",
status: "stopped",
agentKind: "agent",
title: "Run the parallel merge queue dispatcher",
summary: "Orphaned by a previous Claude Code process exit",
},
}),
],
});

const rows = buildThreadFeed(thread).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.workEntry.isBackgroundTask === true)).toBe(true);
expect(rows.some((row) => row.workEntry.agentSpawn !== undefined)).toBe(false);
});
});
46 changes: 37 additions & 9 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Option from "effect/Option";
import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input";
import { collectBackgroundTaskIds } from "@t3tools/client-runtime/state/subagentRuntime";
import * as Schema from "effect/Schema";
import {
requestKindFromRequestType,
Expand Down Expand Up @@ -377,7 +378,10 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean {
* Terminal rows are kept regardless — with no Agents surface on mobile they
* are the terminal signal.
*/
function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
function isAgentInternalActivity(
activity: OrchestrationThreadActivity,
backgroundTaskIds: ReadonlySet<string>,
): boolean {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
Expand All @@ -397,7 +401,11 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
}
// An agent's own shells stay internal; the agents themselves fold into
// their batch. A bypassed batch marker keeps its terminal row.
if (typeof payload.taskId === "string" && payload.agentKind === "agent") {
if (
typeof payload.taskId === "string" &&
payload.agentKind === "agent" &&
!backgroundTaskIds.has(payload.taskId)
) {
return false;
}
if (ownedByAgent) {
Expand All @@ -409,18 +417,28 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
}

/** Agent (non-background) task.started rows seed spawn batches. */
function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
function isAgentTaskStartedActivity(
activity: OrchestrationThreadActivity,
backgroundTaskIds: ReadonlySet<string>,
): boolean {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
return typeof payload?.taskId === "string" && payload.agentKind === "agent";
return (
typeof payload?.taskId === "string" &&
payload.agentKind === "agent" &&
!backgroundTaskIds.has(payload.taskId)
);
}

function deriveWorkLogEntries(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): DerivedWorkLogEntry[] {
const ordered = Arr.sort(activities, activityOrder);
// Resolved across the thread: a task that names a background type stays
// background even when a later bare terminal row carries the agent stamp.
const backgroundTaskIds = collectBackgroundTaskIds(ordered);
const entries: DerivedWorkLogEntry[] = [];
for (const activity of foldUserInputActivities(ordered)) {
// Ownership receipts feed Relay recovery and control routing, not the transcript.
Expand All @@ -436,7 +454,11 @@ function deriveWorkLogEntries(
// id and timestamp, unlike progress ticks, whose stable per-task id is
// rewritten with a new createdAt on every update (and would otherwise
// make the batch row a "fresh" row again on each tick).
if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
if (
activity.kind === "task.started" &&
!isAgentTaskStartedActivity(activity, backgroundTaskIds)
)
continue;
if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) {
const payload = asRecord(activity.payload);
if (payload?.agentKind !== "agent" || typeof payload.taskId !== "string") continue;
Expand All @@ -455,8 +477,8 @@ function deriveWorkLogEntries(
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
if (isAgentInternalActivity(activity, backgroundTaskIds)) continue;
entries.push(toDerivedWorkLogEntry(activity, backgroundTaskIds));
}
return collapseDerivedWorkLogEntries(entries);
}
Expand Down Expand Up @@ -486,7 +508,10 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool

const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);

function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
function toDerivedWorkLogEntry(
activity: OrchestrationThreadActivity,
backgroundTaskIds: ReadonlySet<string>,
): DerivedWorkLogEntry {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
Expand Down Expand Up @@ -545,7 +570,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
entry.toolCallId = toolCallId;
}
if (isTaskActivity && payload) {
if (payload.agentKind !== "agent") {
if (
payload.agentKind !== "agent" ||
(typeof payload.taskId === "string" && backgroundTaskIds.has(payload.taskId))
) {
entry.isBackgroundTask = true;
}
const spawnToolCallId = asTrimmedString(payload.toolUseId);
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2721,3 +2721,52 @@ describe("session activity performance", () => {
expect(updateMs).toBeLessThan(fromScratchMs / 2);
});
});

describe("orphaned background tasks never become spawn cards", () => {
// A watcher orphaned by a provider process exit is settled by the NEXT
// process, which reports only the task id and status. Ingestion stamps that
// bare row "agent"; judging it alone minted a "Ran 1 subagent · Status
// unavailable" card the Agents panel could never resolve.
const rows = [
makeActivity({
kind: "task.started",
summary: "local_bash task started",
turnId: "turn-1",
payload: {
taskId: "watcher-1",
taskType: "local_bash",
title: "Run the parallel merge queue dispatcher",
},
}),
makeActivity({
kind: "task.completed",
summary: "Task stopped",
createdAt: "2026-02-23T01:00:00.000Z",
payload: {
taskId: "watcher-1",
status: "stopped",
title: "Run the parallel merge queue dispatcher",
summary: "Orphaned by a previous Claude Code process exit",
agentKind: "agent",
},
}),
];

it("leaves the settled watcher out of the spawn cards", () => {
expect(deriveWorkLogEntries(rows).filter((entry) => entry.agentSpawn !== undefined)).toEqual(
[],
);
});

it("still raises a spawn card for a real subagent", () => {
const spawned = deriveWorkLogEntries([
makeActivity({
kind: "task.started",
summary: "subagent task started",
turnId: "turn-1",
payload: { taskId: "task-1", taskType: "subagent", title: "Reviewer" },
}),
]).filter((entry) => entry.agentSpawn !== undefined);
expect(spawned.map((entry) => entry.agentSpawn?.agentTaskIds)).toEqual([["task-1"]]);
});
});
47 changes: 38 additions & 9 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { shallow } from "zustand/vanilla/shallow";
import {
deriveAgentPanelModel,
foldSubagentActivities,
collectBackgroundTaskIds,
isBackgroundTaskActivity,
} from "@t3tools/client-runtime/state/subagentRuntime";
import {
Expand Down Expand Up @@ -497,18 +498,24 @@ export function hasActionableProposedPlan(
* Unattributed rows always stay: over-hiding loses the only terminal signal.
*/
/** Agent (non-background) task.started rows seed spawn CTA batches. */
function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
function isAgentTaskStartedActivity(
activity: OrchestrationThreadActivity,
backgroundTaskIds: ReadonlySet<string>,
): boolean {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
if (!payload || typeof payload.taskId !== "string") {
return false;
}
return !isBackgroundTaskActivity(payload);
return !isBackgroundTaskActivity(payload, backgroundTaskIds);
}

function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
function isAgentInternalActivity(
activity: OrchestrationThreadActivity,
backgroundTaskIds: ReadonlySet<string>,
): boolean {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
Expand All @@ -535,7 +542,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
const isAgentTaskRow =
activity.kind !== "task.updated" &&
typeof payload.taskId === "string" &&
!isBackgroundTaskActivity(payload);
!isBackgroundTaskActivity(payload, backgroundTaskIds);
return !isAgentTaskRow;
}
return false;
Expand All @@ -551,6 +558,11 @@ export function deriveWorkLogEntries(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): WorkLogEntry[] {
const ordered = [...activities].toSorted(compareActivitiesByOrder);
// A task's agent-vs-background identity is resolved from the whole thread,
// not from each row: an orphaned shell task settled by a later process
// reports only its id and status, and judging that bare row alone turned
// watchers into phantom subagent cards.
const backgroundTaskIds = collectBackgroundTaskIds(ordered);
const entries: DerivedWorkLogEntry[] = [];
for (const activity of foldUserInputActivities(ordered)) {
// Ownership receipts feed Relay recovery and control routing, not the transcript.
Expand All @@ -572,7 +584,11 @@ export function deriveWorkLogEntries(
// which is the batch key (completions of background subagents arrive
// under later synthetic turns and must not start new batches). They
// collapse into the batch's single CTA row, never render standalone.
if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
if (
activity.kind === "task.started" &&
!isAgentTaskStartedActivity(activity, backgroundTaskIds)
)
continue;
if (activity.kind === "task.updated") continue;
if (activity.kind === "tool.progress") continue;
if (
Expand All @@ -589,8 +605,8 @@ export function deriveWorkLogEntries(
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
if (isAgentInternalActivity(activity, backgroundTaskIds)) continue;
entries.push(toDerivedWorkLogEntry(activity, backgroundTaskIds));
}
return collapseDerivedWorkLogEntries(entries);
}
Expand Down Expand Up @@ -620,9 +636,22 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool

const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload);

function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
function toDerivedWorkLogEntry(
activity: OrchestrationThreadActivity,
backgroundTaskIds: ReadonlySet<string>,
): DerivedWorkLogEntry {
const cachedEntry = derivedWorkLogEntryByActivity.get(activity);
if (cachedEntry) {
// The row itself is immutable, but its task's classification is resolved
// from the whole thread, which grows: a later background row can settle a
// task whose row was already derived. Re-read that one flag on a hit.
if (
cachedEntry.taskId !== undefined &&
cachedEntry.isBackgroundTask !== true &&
backgroundTaskIds.has(cachedEntry.taskId)
) {
cachedEntry.isBackgroundTask = true;
}
return cachedEntry;
}
const payload =
Expand Down Expand Up @@ -749,7 +778,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
) {
entry.isWorkflowCoordinator = true;
}
if (isTaskActivity && payload && isBackgroundTaskActivity(payload)) {
if (isTaskActivity && payload && isBackgroundTaskActivity(payload, backgroundTaskIds)) {
entry.isBackgroundTask = true;
}
const collapseKey = deriveToolLifecycleCollapseKey(entry);
Expand Down
56 changes: 56 additions & 0 deletions packages/client-runtime/src/state/subagentRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import { classifyTaskAgentKind, type OrchestrationThreadActivity } from "@t3tools/contracts";
import {
canCancelSessionAgent,
collectBackgroundTaskIds,
canMessageSessionAgent,
deriveAgentPanelModel,
foldSubagentActivities,
Expand Down Expand Up @@ -1472,3 +1473,58 @@ describe("session agent messaging", () => {
expect(isSessionAgentMessageDeliveryUnknown(new Error("failed"))).toBe(false);
});
});

describe("task classification is resolved per task, not per row", () => {
// A watcher orphaned by a provider process exit is settled by the NEXT
// process, which knows only the task id and status. Ingestion stamps that
// bare row "agent", and judging it alone turned shell watchers into phantom
// subagents in the transcript (live finding, thread 459f4e04).
const orphanSettlement = [
activity("task.started", {
taskId: "watcher-1",
taskType: "local_bash",
title: "Run the parallel merge queue dispatcher",
}),
activity("task.completed", {
taskId: "watcher-1",
status: "stopped",
title: "Run the parallel merge queue dispatcher",
summary: "Orphaned by a previous Claude Code process exit",
agentKind: "agent",
}),
];

it("keeps an orphaned shell watcher off the Agents surface", () => {
expect(collectBackgroundTaskIds(orphanSettlement)).toEqual(new Set(["watcher-1"]));
expect(foldSubagentActivities(orphanSettlement)).toEqual([]);
});

it("resolves the same way when the bare terminal row folds first", () => {
expect(foldSubagentActivities([...orphanSettlement].reverse())).toEqual([]);
});

it("still admits a worker that never carries a task type", () => {
// Relay workers and native subagents legitimately have no taskType; the
// stamp is the only evidence, so it must keep deciding for them.
const relay = [
activity("task.started", {
taskId: "relay:job-1",
title: "Relay worker",
agentKind: "agent",
}),
activity("task.completed", { taskId: "relay:job-1", status: "completed" }),
];
expect(collectBackgroundTaskIds(relay)).toEqual(new Set());
expect(foldSubagentActivities(relay).map((agent) => agent.id)).toEqual(["relay:job-1"]);
});

it("does not let an unstamped row demote a real subagent", () => {
// Absence of a stamp is absence of evidence, not background evidence.
const subagent = [
activity("task.started", { taskId: "task-1", taskType: "subagent", title: "Reviewer" }),
activity("task.completed", { taskId: "task-1", status: "completed", agentKind: undefined }),
];
expect(collectBackgroundTaskIds(subagent)).toEqual(new Set());
expect(foldSubagentActivities(subagent).map((agent) => agent.id)).toEqual(["task-1"]);
});
});
Loading
Loading