Skip to content

Commit 513b603

Browse files
author
T3 Code Test
committed
fix(orchestration-v2): show nested agents in parent fleet
1 parent b2bae43 commit 513b603

5 files changed

Lines changed: 320 additions & 29 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import {
3+
EventId,
4+
NodeId,
5+
ProviderDriverKind,
6+
ProviderInstanceId,
7+
ThreadId,
8+
type ApplicationStoredEvent,
9+
type OrchestrationV2Subagent,
10+
} from "@t3tools/contracts";
11+
import * as DateTime from "effect/DateTime";
12+
13+
import { routeSubagentTreeEvent } from "./SubagentTreeProjection.ts";
14+
15+
const now = DateTime.makeUnsafe("2026-08-18T00:00:00.000Z");
16+
17+
function subagent(input: {
18+
readonly id: string;
19+
readonly threadId: ThreadId;
20+
readonly childThreadId: ThreadId;
21+
}): OrchestrationV2Subagent {
22+
return {
23+
id: NodeId.make(input.id),
24+
threadId: input.threadId,
25+
runId: null,
26+
parentNodeId: NodeId.make("parent"),
27+
origin: "provider_native",
28+
createdBy: "agent",
29+
driver: ProviderDriverKind.make("codex"),
30+
providerInstanceId: ProviderInstanceId.make("codex"),
31+
providerThreadId: null,
32+
childThreadId: input.childThreadId,
33+
nativeTaskRef: null,
34+
prompt: "",
35+
title: input.id,
36+
model: "gpt-5.6-sol",
37+
kind: "subagent",
38+
role: { name: "general-purpose", source: "app_default" },
39+
status: "running",
40+
result: null,
41+
usage: null,
42+
currentActivationId: null,
43+
activationCount: 1,
44+
workflow: null,
45+
workflowMembership: null,
46+
recentActivity: [],
47+
startedAt: now,
48+
completedAt: null,
49+
updatedAt: now,
50+
};
51+
}
52+
53+
describe("subagent tree projection", () => {
54+
it("routes nested lifecycle rows to the root fleet and discovers deeper children", () => {
55+
const rootThreadId = ThreadId.make("root-thread");
56+
const childThreadId = ThreadId.make("child-thread");
57+
const grandchildThreadId = ThreadId.make("grandchild-thread");
58+
const nested = subagent({
59+
id: "nested-agent",
60+
threadId: childThreadId,
61+
childThreadId: grandchildThreadId,
62+
});
63+
64+
const stored = {
65+
sequence: 2,
66+
commandId: null,
67+
event: {
68+
id: EventId.make("nested-update"),
69+
type: "subagent.updated",
70+
threadId: childThreadId,
71+
nodeId: nested.id,
72+
occurredAt: now,
73+
payload: nested,
74+
},
75+
} satisfies ApplicationStoredEvent;
76+
const [next, routed] = routeSubagentTreeEvent(
77+
{ rootThreadId, threadIds: new Set([rootThreadId, childThreadId]) },
78+
stored,
79+
);
80+
81+
expect(routed[0]?.event.threadId).toBe(rootThreadId);
82+
expect(routed[0]?.event.type).toBe("subagent.updated");
83+
expect(next.threadIds.has(grandchildThreadId)).toBe(true);
84+
});
85+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type {
2+
ApplicationStoredEvent,
3+
OrchestrationV2StoredEvent,
4+
OrchestrationV2ThreadProjection,
5+
ThreadId,
6+
} from "@t3tools/contracts";
7+
8+
function upsertById<T extends { readonly id: string }>(
9+
target: Map<string, T>,
10+
values: ReadonlyArray<T>,
11+
) {
12+
for (const value of values) target.set(value.id, value);
13+
}
14+
15+
/** Add descendant agent rows to the root projection without copying their transcripts. */
16+
export function mergeSubagentTreeProjection(
17+
root: OrchestrationV2ThreadProjection,
18+
descendants: ReadonlyArray<OrchestrationV2ThreadProjection>,
19+
): OrchestrationV2ThreadProjection {
20+
if (descendants.length === 0) return root;
21+
22+
const subagents = new Map(root.subagents.map((subagent) => [subagent.id, subagent]));
23+
const activations = new Map(
24+
root.subagentActivations.map((activation) => [activation.id, activation]),
25+
);
26+
for (const projection of descendants) {
27+
upsertById(subagents, projection.subagents);
28+
upsertById(activations, projection.subagentActivations);
29+
}
30+
return {
31+
...root,
32+
subagents: Array.from(subagents.values()),
33+
subagentActivations: Array.from(activations.values()),
34+
};
35+
}
36+
37+
export interface SubagentTreeStreamState {
38+
readonly rootThreadId: ThreadId;
39+
readonly threadIds: ReadonlySet<ThreadId>;
40+
}
41+
42+
function withChildThread(
43+
state: SubagentTreeStreamState,
44+
childThreadId: ThreadId | null,
45+
): SubagentTreeStreamState {
46+
if (childThreadId === null || state.threadIds.has(childThreadId)) return state;
47+
return { ...state, threadIds: new Set([...state.threadIds, childThreadId]) };
48+
}
49+
50+
/**
51+
* Keep the root event stream complete while folding descendant subagent lifecycle
52+
* events into the same client-side fleet projection.
53+
*/
54+
export function routeSubagentTreeEvent(
55+
state: SubagentTreeStreamState,
56+
stored: ApplicationStoredEvent,
57+
): readonly [SubagentTreeStreamState, ReadonlyArray<OrchestrationV2StoredEvent>] {
58+
if (!("event" in stored)) return [state, []];
59+
60+
const event = stored.event;
61+
if (
62+
event.type === "thread.created" &&
63+
event.payload.lineage.relationshipToParent === "subagent" &&
64+
event.payload.lineage.parentThreadId !== null &&
65+
state.threadIds.has(event.payload.lineage.parentThreadId)
66+
) {
67+
return [withChildThread(state, event.payload.id), []];
68+
}
69+
if (event.threadId === state.rootThreadId) {
70+
return [
71+
event.type === "subagent.updated"
72+
? withChildThread(state, event.payload.childThreadId)
73+
: state,
74+
[stored],
75+
];
76+
}
77+
if (!state.threadIds.has(event.threadId)) return [state, []];
78+
if (event.type !== "subagent.updated" && event.type !== "subagent-activation.updated") {
79+
return [state, []];
80+
}
81+
82+
const next =
83+
event.type === "subagent.updated" ? withChildThread(state, event.payload.childThreadId) : state;
84+
return [
85+
next,
86+
[
87+
{
88+
...stored,
89+
event: { ...event, threadId: state.rootThreadId },
90+
},
91+
],
92+
];
93+
}

apps/server/src/ws.ts

Lines changed: 117 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
OrchestrationV2ThreadLaunchError,
3333
type OrchestrationProjectShell,
3434
type OrchestrationV2ShellSnapshot,
35+
type OrchestrationV2ThreadProjection,
3536
type ProjectEntriesFailure,
3637
type ProjectFileFailure,
3738
type ProjectFileOperation,
@@ -95,6 +96,10 @@ import {
9596
projectDomainEventForWire,
9697
projectThreadProjectionForWire,
9798
} from "./orchestration-v2/WireProjection.ts";
99+
import {
100+
mergeSubagentTreeProjection,
101+
routeSubagentTreeEvent,
102+
} from "./orchestration-v2/SubagentTreeProjection.ts";
98103
import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
99104
import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
100105
import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
@@ -624,27 +629,61 @@ const makeWsRpcLayer = (
624629
),
625630
);
626631

627-
const eventStreamFrom = (afterSequence: number) =>
628-
threadManagement
629-
.streamStoredEventsFrom({
630-
threadId: input.threadId,
631-
afterSequence,
632-
})
633-
.pipe(
634-
Stream.map((stored) => ({
635-
kind: "event" as const,
636-
sequence: stored.sequence,
637-
event: projectDomainEventForWire(stored.event),
638-
})),
639-
Stream.mapError(
640-
(cause) =>
641-
new OrchestrationV2GetThreadProjectionError({
642-
threadId: input.threadId,
643-
message: `Failed while streaming orchestration V2 thread ${input.threadId}`,
644-
cause,
645-
}),
632+
const loadSubagentTree = Effect.fn("ws.orchestrationV2.loadSubagentTree")(function* (
633+
root: OrchestrationV2ThreadProjection,
634+
) {
635+
const threadIds = new Set<ThreadId>([root.thread.id]);
636+
const descendants: OrchestrationV2ThreadProjection[] = [];
637+
let frontier = root.subagents.flatMap((subagent) =>
638+
subagent.childThreadId === null ? [] : [subagent.childThreadId],
639+
);
640+
641+
while (frontier.length > 0) {
642+
const nextIds = [...new Set(frontier)].filter((threadId) => !threadIds.has(threadId));
643+
if (nextIds.length === 0) break;
644+
for (const threadId of nextIds) threadIds.add(threadId);
645+
const projections = yield* Effect.forEach(
646+
nextIds,
647+
(threadId) => threadManagement.getThreadProjection(threadId),
648+
{ concurrency: 8 },
649+
);
650+
descendants.push(...projections);
651+
frontier = projections.flatMap((projection) =>
652+
projection.subagents.flatMap((subagent) =>
653+
subagent.childThreadId === null ? [] : [subagent.childThreadId],
646654
),
647655
);
656+
}
657+
658+
return {
659+
threadIds,
660+
projection: mergeSubagentTreeProjection(root, descendants),
661+
};
662+
});
663+
664+
const eventStreamFrom = (
665+
afterSequence: number,
666+
initialThreadIds: ReadonlySet<ThreadId>,
667+
) =>
668+
applicationEvents.streamApplicationEvents({ afterSequence }).pipe(
669+
Stream.mapAccum(
670+
() => ({ rootThreadId: input.threadId, threadIds: initialThreadIds }),
671+
routeSubagentTreeEvent,
672+
),
673+
Stream.map((stored) => ({
674+
kind: "event" as const,
675+
sequence: stored.sequence,
676+
event: projectDomainEventForWire(stored.event),
677+
})),
678+
Stream.mapError(
679+
(cause) =>
680+
new OrchestrationV2GetThreadProjectionError({
681+
threadId: input.threadId,
682+
message: `Failed while streaming orchestration V2 thread ${input.threadId}`,
683+
cause,
684+
}),
685+
),
686+
);
648687

649688
const loadReplayThrough = (afterSequence: number, throughSequence: number) =>
650689
applicationEvents
@@ -679,6 +718,16 @@ const makeWsRpcLayer = (
679718

680719
const snapshotThenLive = Effect.fn("ws.orchestrationV2.threadSnapshotThenLive")(
681720
function* () {
721+
const snapshotSequence = yield* applicationEvents.latestApplicationSequence.pipe(
722+
Effect.mapError(
723+
(cause) =>
724+
new OrchestrationV2GetThreadProjectionError({
725+
threadId: input.threadId,
726+
message: `Failed to prepare orchestration V2 thread ${input.threadId} snapshot`,
727+
cause,
728+
}),
729+
),
730+
);
682731
const snapshot = yield* threadManagement.getThreadSnapshot(input.threadId).pipe(
683732
Effect.mapError(
684733
(cause) =>
@@ -689,8 +738,17 @@ const makeWsRpcLayer = (
689738
}),
690739
),
691740
);
692-
const { snapshotSequence } = snapshot;
693-
const projection = projectThreadProjectionForWire(snapshot.projection);
741+
const tree = yield* loadSubagentTree(snapshot.projection).pipe(
742+
Effect.mapError(
743+
(cause) =>
744+
new OrchestrationV2GetThreadProjectionError({
745+
threadId: input.threadId,
746+
message: `Failed to load nested agents for orchestration V2 thread ${input.threadId}`,
747+
cause,
748+
}),
749+
),
750+
);
751+
const projection = projectThreadProjectionForWire(tree.projection);
694752
return Stream.concat(
695753
Stream.concat(
696754
Stream.make({
@@ -700,7 +758,7 @@ const makeWsRpcLayer = (
700758
}),
701759
completionMarker,
702760
),
703-
eventStreamFrom(snapshotSequence),
761+
eventStreamFrom(snapshotSequence, tree.threadIds),
704762
);
705763
},
706764
);
@@ -713,7 +771,7 @@ const makeWsRpcLayer = (
713771
// published during the replay window is lost; overlapping events are
714772
// deduped by sequence on the client.
715773
if (input.afterSequence !== undefined) {
716-
const highWater = yield* applicationEvents.latestAgentSequence(input.threadId).pipe(
774+
const highWater = yield* applicationEvents.latestApplicationSequence.pipe(
717775
Effect.mapError(
718776
(cause) =>
719777
new OrchestrationV2GetThreadProjectionError({
@@ -723,6 +781,41 @@ const makeWsRpcLayer = (
723781
}),
724782
),
725783
);
784+
const currentProjection = yield* threadManagement
785+
.getThreadProjection(input.threadId)
786+
.pipe(
787+
Effect.mapError(
788+
(cause) =>
789+
new OrchestrationV2GetThreadProjectionError({
790+
threadId: input.threadId,
791+
message: `Failed to inspect nested agents for orchestration V2 thread ${input.threadId}`,
792+
cause,
793+
}),
794+
),
795+
);
796+
const currentTree = yield* loadSubagentTree(currentProjection).pipe(
797+
Effect.mapError(
798+
(cause) =>
799+
new OrchestrationV2GetThreadProjectionError({
800+
threadId: input.threadId,
801+
message: `Failed to inspect nested agents for orchestration V2 thread ${input.threadId}`,
802+
cause,
803+
}),
804+
),
805+
);
806+
if (currentTree.threadIds.size > 1) {
807+
return Stream.concat(
808+
Stream.concat(
809+
Stream.make({
810+
kind: "snapshot" as const,
811+
snapshotSequence: highWater,
812+
projection: projectThreadProjectionForWire(currentTree.projection),
813+
}),
814+
completionMarker,
815+
),
816+
eventStreamFrom(highWater, currentTree.threadIds),
817+
);
818+
}
726819
const replay = yield* loadReplayThrough(input.afterSequence, highWater);
727820
const plan = decideThreadResume({
728821
afterSequence: input.afterSequence,
@@ -735,7 +828,7 @@ const makeWsRpcLayer = (
735828
}
736829
return Stream.concat(
737830
Stream.concat(Stream.fromIterable(replay), completionMarker),
738-
eventStreamFrom(highWater),
831+
eventStreamFrom(highWater, currentTree.threadIds),
739832
);
740833
}
741834

0 commit comments

Comments
 (0)