Skip to content

Commit a2ca89a

Browse files
t3dotggclaude
andauthored
feat: native subagent & workflow observability (pingdotgg#5219)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 990bb0b commit a2ca89a

46 files changed

Lines changed: 6871 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/src/lib/threadActivity.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,3 +532,39 @@ describe("buildThreadFeed", () => {
532532
});
533533
});
534534
});
535+
536+
describe("quiet timeline: nested agents", () => {
537+
it("keeps a nested agent's terminal row but hides its background work", () => {
538+
const thread = makeThread({
539+
id: ThreadId.make("thread-nested"),
540+
projectId: ProjectId.make("project-1"),
541+
title: "Nested agents",
542+
activities: [
543+
// A subagent's own shell: internal, covered by the owner's liveness.
544+
makeActivity({
545+
id: EventId.make("shell-done"),
546+
kind: "task.completed",
547+
summary: "Task completed",
548+
createdAt: "2026-04-01T00:00:02.000Z",
549+
payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" },
550+
}),
551+
// A nested AGENT's completion: mobile has no Agents sheet, so this
552+
// terminal row is the only signal it ever finished.
553+
makeActivity({
554+
id: EventId.make("nested-done"),
555+
kind: "task.completed",
556+
summary: "Task completed",
557+
createdAt: "2026-04-01T00:00:03.000Z",
558+
payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" },
559+
}),
560+
],
561+
});
562+
563+
const feed = buildThreadFeed(thread);
564+
const ids = feed.flatMap((entry) =>
565+
entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
566+
);
567+
expect(ids).toContain("nested-done");
568+
expect(ids).not.toContain("shell-done");
569+
});
570+
});

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ interface WorkLogEntry {
8080
interface DerivedWorkLogEntry extends WorkLogEntry {
8181
activityKind: OrchestrationThreadActivity["kind"];
8282
collapseKey?: string;
83+
/** Grouping key for subagent lifecycle rows (one row per agent). */
84+
taskId?: string;
8385
}
8486

8587
type RawThreadFeedEntry =
@@ -235,6 +237,63 @@ function resolvePendingUserInputAnswer(
235237
return normalizeDraftAnswer(draft?.selectedOptionLabel);
236238
}
237239

240+
/** Codex children settle via task.updated (idle/failed/interrupted), never
241+
* task.completed — these rows are mobile's only terminal signal for them. */
242+
const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([
243+
"idle",
244+
"completed",
245+
"failed",
246+
"cancelled",
247+
"interrupted",
248+
]);
249+
250+
function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean {
251+
if (activity.kind !== "task.updated") {
252+
return false;
253+
}
254+
const payload =
255+
activity.payload && typeof activity.payload === "object"
256+
? (activity.payload as Record<string, unknown>)
257+
: null;
258+
return (
259+
payload?.timelineBypass === true &&
260+
typeof payload.status === "string" &&
261+
MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status)
262+
);
263+
}
264+
265+
/**
266+
* Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
267+
* activity lives in the Agents sheet, not the work log. Terminal rows are
268+
* kept — with no Agents surface on mobile they are the terminal signal
269+
* (a surface that hides rows must keep its own terminal signal). That means
270+
* task.completed (Claude) AND terminal bypassed task.updated (Codex, whose
271+
* children never emit task.completed — review finding).
272+
*/
273+
function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
274+
const payload =
275+
activity.payload && typeof activity.payload === "object"
276+
? (activity.payload as Record<string, unknown>)
277+
: null;
278+
if (!payload) {
279+
return false;
280+
}
281+
const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity);
282+
if (payload.timelineBypass === true && !isTerminalTaskRow) {
283+
return true;
284+
}
285+
// agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is
286+
// the only signal mobile gets (no Agents sheet), so it stays. Only an
287+
// agent's own background work (stamped "background") is internal — same
288+
// rule as web (review finding: hiding on agentId alone dropped nested
289+
// completions with no replacement UI).
290+
const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
291+
if (!ownedByAgent) {
292+
return false;
293+
}
294+
return !(isTerminalTaskRow && payload.agentKind === "agent");
295+
}
296+
238297
function deriveWorkLogEntries(
239298
activities: ReadonlyArray<OrchestrationThreadActivity>,
240299
): DerivedWorkLogEntry[] {
@@ -243,9 +302,13 @@ function deriveWorkLogEntries(
243302
for (const activity of ordered) {
244303
if (activity.kind === "tool.started") continue;
245304
if (activity.kind === "task.started") continue;
305+
// Terminal bypassed updates pass: Codex children's only terminal signal.
306+
if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
307+
if (activity.kind === "tool.progress") continue;
246308
if (activity.kind === "context-window.updated") continue;
247309
if (activity.summary === "Checkpoint captured") continue;
248310
if (isPlanBoundaryToolActivity(activity)) continue;
311+
if (isAgentInternalActivity(activity)) continue;
249312
entries.push(toDerivedWorkLogEntry(activity));
250313
}
251314
return collapseDerivedWorkLogEntries(entries);
@@ -271,7 +334,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
271334
const commandPreview = extractToolCommand(payload);
272335
const changedFiles = extractChangedFiles(payload);
273336
const title = extractToolTitle(payload);
274-
const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
337+
// task.updated included: terminal bypassed updates (Codex children's only
338+
// terminal signal) must carry task identity so they collapse per child
339+
// instead of stacking anonymous "Task idle" rows.
340+
const isTaskActivity =
341+
activity.kind === "task.progress" ||
342+
activity.kind === "task.completed" ||
343+
activity.kind === "task.updated";
275344
const taskSummary =
276345
isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
277346
? payload.summary
@@ -284,10 +353,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
284353
? payload.detail
285354
: null;
286355
const taskLabel = taskSummary || taskDetailAsLabel;
356+
const taskId =
357+
isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
358+
? payload.taskId
359+
: undefined;
287360
const entry: DerivedWorkLogEntry = {
288361
id: activity.id,
289362
createdAt: activity.createdAt,
290363
turnId: activity.turnId,
364+
...(taskId ? { taskId } : {}),
291365
label: taskLabel || activity.summary,
292366
tone:
293367
activity.kind === "task.progress"
@@ -352,7 +426,25 @@ function collapseDerivedWorkLogEntries(
352426
entries: ReadonlyArray<DerivedWorkLogEntry>,
353427
): DerivedWorkLogEntry[] {
354428
const collapsed: DerivedWorkLogEntry[] = [];
429+
// Subagent rows collapse by identity, not adjacency (quiet-timeline
430+
// guarantee; mirrors web's session-logic).
431+
const taskRowIndex = new Map<string, number>();
355432
for (const entry of entries) {
433+
const isTaskRow =
434+
entry.taskId !== undefined &&
435+
(entry.activityKind === "task.progress" ||
436+
entry.activityKind === "task.completed" ||
437+
entry.activityKind === "task.updated");
438+
if (isTaskRow && entry.taskId !== undefined) {
439+
const existingIndex = taskRowIndex.get(entry.taskId);
440+
if (existingIndex !== undefined) {
441+
collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
442+
continue;
443+
}
444+
taskRowIndex.set(entry.taskId, collapsed.length);
445+
collapsed.push(entry);
446+
continue;
447+
}
356448
const previous = collapsed.at(-1);
357449
if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
358450
collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);

‎apps/server/integration/OrchestrationEngineHarness.integration.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe
5050
import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
5151
import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5252
import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
53+
import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
5354
import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
5455
import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
5556
import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
@@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = (
305306
checkpointStoreLayer,
306307
providerLayer,
307308
RuntimeReceiptBusTest,
308-
);
309+
).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
309310
const serverSettingsLayer = ServerSettingsService.layerTest();
310311
const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
311312
Layer.provideMerge(runtimeServicesLayer),

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
2222
*/
2323
export const RPC_REQUIRED_SCOPES = {
2424
[ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
25+
[ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
2526
[ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
2627
[ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
2728
[ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import type { OrchestrationThreadActivity } from "@t3tools/contracts";
3+
import { projectActivityPayload } from "./ActivityPayloadProjection.ts";
4+
5+
function activity(payload: Record<string, unknown>): OrchestrationThreadActivity {
6+
return {
7+
id: "activity-1",
8+
tone: "tool",
9+
kind: "tool.completed",
10+
summary: "Tool",
11+
payload,
12+
turnId: null,
13+
createdAt: "2026-08-01T10:00:00.000Z",
14+
} as unknown as OrchestrationThreadActivity;
15+
}
16+
17+
/**
18+
* Wire-survival regression: the slimming pass rewrites payload.data but must
19+
* never strip the top-level per-agent fields the subagent fold depends on.
20+
* If slimming ever moves to an allowlist over the whole payload, these
21+
* assertions are the tripwire.
22+
*/
23+
describe("projectActivityPayload agent-field survival", () => {
24+
it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
25+
const projected = projectActivityPayload(
26+
activity({
27+
itemType: "command_execution",
28+
agentId: "task-123",
29+
parentToolUseId: "toolu_abc",
30+
data: {
31+
toolName: "Bash",
32+
input: { command: "ls" },
33+
command: "ls",
34+
rawOutput: { content: "x".repeat(10) },
35+
somethingClientNeverReads: { big: "blob" },
36+
},
37+
}),
38+
);
39+
const payload = projected.payload as Record<string, unknown>;
40+
expect(payload.agentId).toBe("task-123");
41+
expect(payload.parentToolUseId).toBe("toolu_abc");
42+
// Slimming itself still applies to data.
43+
const data = payload.data as Record<string, unknown>;
44+
expect(data.somethingClientNeverReads).toBeUndefined();
45+
});
46+
47+
it("passes task lifecycle payloads (no data field) through untouched", () => {
48+
const source = activity({
49+
taskId: "task-9",
50+
title: "Audit auth",
51+
role: "explorer",
52+
model: "opus",
53+
effort: "high",
54+
workflowName: "audit-flow",
55+
phases: [{ index: 0, title: "Audit" }],
56+
typedUsage: { totalTokens: 1200 },
57+
runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" },
58+
timelineBypass: true,
59+
});
60+
const projected = projectActivityPayload(source);
61+
expect(projected.payload).toEqual(source.payload);
62+
});
63+
});

‎apps/server/src/orchestration/Layers/CheckpointReactor.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts";
3939
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4040
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4141
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
42+
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
4243
import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
4344
import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
4445
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
@@ -294,13 +295,15 @@ describe("CheckpointReactor", () => {
294295
);
295296
const orchestrationLayer = OrchestrationEngineLive.pipe(
296297
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
298+
Layer.provide(ThreadBackgroundLiveness.layer),
297299
Layer.provide(OrchestrationProjectionPipelineLive),
298300
Layer.provide(OrchestrationEventStoreLive),
299301
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
300302
Layer.provide(RepositoryIdentityResolver.layer),
301303
Layer.provide(SqlitePersistenceMemory),
302304
);
303305
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
306+
Layer.provide(ThreadBackgroundLiveness.layer),
304307
Layer.provide(RepositoryIdentityResolver.layer),
305308
Layer.provide(SqlitePersistenceMemory),
306309
);

‎apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
3131
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3232
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3333
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34+
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
3435
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3536
import {
3637
OrchestrationProjectionPipeline,
@@ -55,6 +56,7 @@ async function createOrchestrationSystem() {
5556
),
5657
OrchestrationProjectionSnapshotQueryLive,
5758
).pipe(
59+
Layer.provide(ThreadBackgroundLiveness.layer),
5860
Layer.provide(OrchestrationEventStoreLive),
5961
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
6062
Layer.provide(RepositoryIdentityResolver.layer),
@@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => {
817819
const runtime = ManagedRuntime.make(
818820
OrchestrationEngineLive.pipe(
819821
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
822+
Layer.provide(ThreadBackgroundLiveness.layer),
820823
Layer.provide(OrchestrationProjectionPipelineLive),
821824
Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
822825
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
@@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => {
922925
const runtime = ManagedRuntime.make(
923926
OrchestrationEngineLive.pipe(
924927
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
928+
Layer.provide(ThreadBackgroundLiveness.layer),
925929
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
926930
Layer.provide(OrchestrationEventStoreLive),
927931
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
@@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => {
10651069
const runtime = ManagedRuntime.make(
10661070
OrchestrationEngineLive.pipe(
10671071
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
1072+
Layer.provide(ThreadBackgroundLiveness.layer),
10681073
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
10691074
Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
10701075
Layer.provide(OrchestrationCommandReceiptRepositoryLive),

‎apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
OrchestrationProjectionPipelineLive,
3232
} from "./ProjectionPipeline.ts";
3333
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34+
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
3435
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3536
import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
3637
import { ServerConfig } from "../../config.ts";
@@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
26642665
const engineLayer = it.layer(
26652666
OrchestrationEngineLive.pipe(
26662667
Layer.provide(OrchestrationProjectionSnapshotQueryLive),
2668+
Layer.provide(ThreadBackgroundLiveness.layer),
26672669
Layer.provide(OrchestrationProjectionPipelineLive),
26682670
Layer.provide(OrchestrationEventStoreLive),
26692671
Layer.provide(OrchestrationCommandReceiptRepositoryLive),

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
1717
import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
1818
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
1919
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
20+
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
2021
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2122

2223
const asProjectId = (value: string): ProjectId => ProjectId.make(value);
@@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
2728

2829
const projectionSnapshotLayer = it.layer(
2930
OrchestrationProjectionSnapshotQueryLive.pipe(
31+
Layer.provide(ThreadBackgroundLiveness.layer),
3032
Layer.provideMerge(RepositoryIdentityResolver.layer),
3133
Layer.provideMerge(SqlitePersistenceMemory),
3234
Layer.provideMerge(NodeServices.layer),
@@ -443,6 +445,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
443445
hasPendingApprovals: true,
444446
hasPendingUserInput: false,
445447
hasActionableProposedPlan: false,
448+
backgroundLiveness: null,
446449
},
447450
]);
448451

@@ -1825,6 +1828,7 @@ it.effect(
18251828
() => {
18261829
const resolveCalls: string[] = [];
18271830
const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
1831+
Layer.provide(ThreadBackgroundLiveness.layer),
18281832
Layer.provideMerge(
18291833
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
18301834
resolve: (cwd: string) =>

0 commit comments

Comments
 (0)