Skip to content
78 changes: 76 additions & 2 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2955,10 +2955,11 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () =>
readonly root?: string;
readonly worktreePath?: string;
readonly archived?: boolean;
readonly native?: boolean;
}) =>
({
createdBy: "user",
creationSource: input.parent === undefined ? "web" : "mcp",
creationSource: input.native ? "provider" : input.parent === undefined ? "web" : "mcp",
id: id(input.name),
projectId,
title: input.name,
Expand Down Expand Up @@ -2993,7 +2994,7 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () =>
});
yield* createThread({ name: "root", worktreePath });
yield* createThread({ name: "child", parent: "root", root: "root", worktreePath });
yield* createThread({ name: "grandchild", parent: "child", root: "root" });
yield* createThread({ name: "grandchild", parent: "child", root: "root", native: true });
yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true });
yield* createThread({ name: "other", worktreePath: "/repo" });
yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath });
Expand All @@ -3008,6 +3009,79 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () =>
assert.sameMembers([...(yield* family(id("grandchild")))], tree);
assert.deepEqual([...(yield* family(id("missing")))], []);

const nativeRootId = NodeId.make("node:family:grandchild-root");
const nativeRoot = {
id: nativeRootId,
threadId: id("grandchild"),
runId: null,
parentNodeId: null,
rootNodeId: nativeRootId,
kind: "root_turn" as const,
countsForRun: false,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
runtimeRequestId: null,
checkpointScopeId: null,
startedAt: now,
completedAt: null,
};
for (const status of ["pending", "running", "completed", "failed", "cancelled"] as const) {
yield* store.apply({
id: EventId.make(`event:family:native-root:${status}`),
type: "node.updated",
threadId: id("grandchild"),
nodeId: nativeRootId,
driver,
occurredAt: now,
payload: { ...nativeRoot, status },
});
const snapshot = yield* store.getShellSnapshot({ location: "active", familyOf: id("root") });
const projection = yield* store.getThreadProjection(id("grandchild"));
const shells = [
yield* store.getThreadShell(id("grandchild")),
snapshot.threads.find((thread) => thread.id === id("grandchild")),
ProjectionStore.threadShellFromProjection(projection),
];
assert.deepEqual(
shells.map((shell) => shell?.status),
Array(3).fill(status === "pending" ? "queued" : status),
);
yield* assertShellIndexCoversShells();
}

const followupRunId = RunId.make("run:family:grandchild-followup");
yield* store.apply({
id: EventId.make("event:family:grandchild-followup"),
type: "run.created",
threadId: id("grandchild"),
runId: followupRunId,
occurredAt: now,
payload: {
id: followupRunId,
threadId: id("grandchild"),
ordinal: 1,
providerInstanceId,
modelSelection,
providerThreadId: null,
userMessageId: MessageId.make("message:family:grandchild-followup"),
rootNodeId: NodeId.make("node:family:grandchild-followup"),
activeAttemptId: null,
status: "running",
requestedAt: now,
startedAt: now,
completedAt: null,
checkpointId: null,
contextHandoffId: null,
},
});
assert.equal((yield* store.getThreadShell(id("grandchild")))?.status, "running");
assert.equal(
ProjectionStore.threadShellFromProjection(yield* store.getThreadProjection(id("grandchild")))
.status,
"running",
);

// Includes another root's idle child on the same path, which a shell may leave out.
const { statements, tracer } = traceSqlStatements();
const onWorktree = yield* store
Expand Down
32 changes: 30 additions & 2 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
IDLE_COMPACTION_TEXT,
idleCompactionOf,
isQueuedProviderThreadPlaceholder,
isProviderNativeSubagentThread,
OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema,
OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema,
OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema,
Expand Down Expand Up @@ -1116,6 +1117,7 @@ type ShellThreadRow = {
readonly forked_from_run_source_thread_id: string | null;
readonly latest_run_id: string | null;
readonly latest_run_status: string | null;
readonly native_root_status: string | null;
readonly latest_run_requested_at: string | null;
readonly latest_run_started_at: string | null;
readonly latest_run_completed_at: string | null;
Expand Down Expand Up @@ -1727,6 +1729,10 @@ export function threadShellFromProjection(
const activityRun =
runs.filter(isActivityRunForShell).toSorted((left, right) => right.ordinal - left.ordinal)[0] ??
null;
const nativeRoot =
latestRun === null && isProviderNativeSubagentThread(projection.thread)
? projection.nodes.findLast((node) => node.kind === "root_turn" && node.runId === null)
: undefined;
const liveRunIds = new Set(runs.filter(isActivityRunForShell).map((run) => run.id));
let pendingRuntimeRequest = pendingRuntimeRequestSummary(
projection.runtimeRequests
Expand Down Expand Up @@ -1826,7 +1832,7 @@ export function threadShellFromProjection(
activityRunStatus: activityRun?.status ?? null,
activityRunStartedAt:
activityRun === null ? null : orchestrationV2RunWorkStartedAt(activityRun),
status: latestRun?.status ?? "idle",
status: latestRun?.status ?? shellStatusFromStoredRunStatus(nativeRoot?.status ?? null),
...threadErrorSummary(
latestRootProviderFailure(latestRun, projection.turnItems),
providerSession?.lastError ?? null,
Expand Down Expand Up @@ -1985,6 +1991,8 @@ function shellStatusFromStoredRunStatus(status: string | null): OrchestrationV2S
switch (status) {
case null:
return "idle";
case "pending":
return "queued";
case "preparing":
case "queued":
case "starting":
Expand Down Expand Up @@ -7770,6 +7778,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
END AS forked_from_run_source_thread_id,
presented.run_id AS latest_run_id,
presented.status AS latest_run_status,
CASE WHEN presented.run_id IS NULL
AND json_extract(t.payload_json, '$.creationSource') = 'provider'
AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent'
THEN (
SELECT node.status FROM orchestration_v2_projection_nodes node
WHERE node.thread_id = t.thread_id AND node.run_id IS NULL AND node.kind = 'root_turn'
ORDER BY COALESCE(node.started_at, '') DESC, node.node_id DESC
LIMIT 1
) END AS native_root_status,
presented.requested_at AS latest_run_requested_at,
json_extract(presented.payload_json, '$.startedAt') AS latest_run_started_at,
presented.completed_at AS latest_run_completed_at,
Expand Down Expand Up @@ -8458,7 +8475,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
? null
: yield* decodeTurnItemPayload(row.terminal_failure_payload_json);
let latestRunId = row.latest_run_id === null ? null : RunId.make(row.latest_run_id);
let latestRunStatus = shellStatusFromStoredRunStatus(row.latest_run_status);
let latestRunStatus = shellStatusFromStoredRunStatus(
row.latest_run_status ?? row.native_root_status,
);
let latestRunRequestedAt =
row.latest_run_requested_at === null
? null
Expand Down Expand Up @@ -8872,6 +8891,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
WHERE r.thread_id = t.thread_id
AND r.status IN ('queued', 'preparing', 'starting', 'running', 'waiting')
)
OR (
json_extract(t.payload_json, '$.creationSource') = 'provider'
AND json_extract(t.payload_json, '$.lineage.relationshipToParent') = 'subagent'
AND EXISTS (
SELECT 1 FROM orchestration_v2_projection_nodes node
WHERE node.thread_id = t.thread_id AND node.run_id IS NULL AND node.kind = 'root_turn'
AND node.status IN ('pending', 'starting', 'running', 'waiting')
)
)
OR EXISTS (
SELECT 1 FROM orchestration_v2_projection_runtime_requests request
WHERE request.thread_id = t.thread_id AND request.status = 'pending'
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration-v2/ShellStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ describe("subagent shell events", () => {
expect(shouldPublishShellApplicationEvent(stored("child", "node.updated"), subagents)).toBe(
false,
);
for (const [kind, runId, expected] of [
["root_turn", null, true],
["root_turn", "run:followup", false],
["tool_call", null, false],
] as const) {
const event = {
event: { threadId: "child", type: "node.updated", payload: { kind, runId } },
} as ApplicationStoredEvent;
expect(shouldPublishShellApplicationEvent(event, subagents)).toBe(expected);
}
expect(
shouldPublishShellApplicationEvent(stored("child", "runtime-request.updated"), subagents),
).toBe(true);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/orchestration-v2/ShellStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,13 @@ export function shouldPublishShellApplicationEvent(
if (event.type === "turn-item.updated" && turnItemCanChangeUserWait(event.payload)) {
return true;
}
if (
event.type === "node.updated" &&
event.payload.kind === "root_turn" &&
event.payload.runId === null
) {
return true;
}
return !(
subagentThreadIds.has(event.threadId) &&
(event.type === "node.updated" ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ vi.mock(import("../../state/query"), async (original) => ({
useEnvironmentQuery: ((atom: unknown) =>
atom === null
? { data: null, isSuccess: false, isPending: false }
: { data: state.threadTitles, isSuccess: true, isPending: false }) as never,
: {
data: state.threadTitles,
isSuccess: true,
isPending: false,
}) as never,
}));

vi.mock(import("@effect/atom-react"), async (original) => ({
Expand Down Expand Up @@ -57,6 +61,11 @@ vi.mock(import("../../state/use-atom-command"), () => ({
: AsyncResult.failure(Cause.fail(new Error("Connection lost")))) as never,
}));

vi.mock(import("../../state/threads"), async (original) => ({
...(await original()),
threadFamilies: { ...(await original()).threadFamilies, mount: () => () => {} },
}));

import { render } from "../../../test/render";
import { makeThreadProjectionFixture } from "../../test-fixtures";
import { ScrollArea } from "../ui/scroll-area";
Expand Down Expand Up @@ -409,6 +418,90 @@ it("hides a deleted fork and keeps unloaded and archived forks with their curren
await expect.element(screen.getByText("deleted-fork", { exact: true })).not.toBeInTheDocument();
});

it.each([false, true])(
"nests subagents while preserving workflow expansion (%s)",
async (workflow) => {
setup();
const projection = makeThreadProjectionFixture();
const environmentId = EnvironmentId.make("local");
const shell = (id: string, title: string, parentThreadId: ThreadId) => ({
id: ThreadId.make(id),
title,
activityRunStatus: "running",
status: "running",
lineage: { parentThreadId, relationshipToParent: "subagent" },
forkedFrom: null,
});
state.projection = {
...projection,
subagents: workflow
? [
{
id: NodeId.make("workflow"),
childThreadId: ThreadId.make("child"),
title: "Camera",
prompt: "Research lighting",
model: null,
status: "running",
result: null,
startedAt: projection.thread.createdAt,
completedAt: null,
updatedAt: projection.thread.updatedAt,
workflow: {
name: "Camera workflow",
phases: [{ index: 1, title: "Research" }],
agents: [
{
index: 1,
label: "Research lighting",
state: "running",
phaseIndex: 1,
phaseTitle: "Research",
childThreadId: ThreadId.make("grandchild"),
},
],
},
},
]
: [],
};
state.relatedShells = [
shell("child", "Camera", projection.thread.id),
shell("grandchild", "Research lighting", ThreadId.make("child")),
];
await using screen = await render(
<ThreadRelationshipsPanel
environmentId={environmentId}
threadId={projection.thread.id}
showLineage
showSideChats
/>,
);

await expect
.element(screen.getByText(workflow ? "Camera workflow" : "Camera", { exact: true }))
.toBeVisible();
if (workflow) {
await expect
.element(screen.getByText("Research lighting", { exact: true }))
.not.toBeInTheDocument();
await screen.getByRole("button", { name: "Expand Camera workflow", exact: true }).click();
await expect.element(screen.getByText("Research lighting", { exact: true })).toBeVisible();
await expect.element(screen.getByText("Lineage · 1 running")).toBeVisible();
await screen.getByRole("button", { name: "Collapse Camera workflow", exact: true }).click();
await expect
.element(screen.getByText("Research lighting", { exact: true }))
.not.toBeInTheDocument();
return;
}
await expect.element(screen.getByRole("button", { name: /Research lighting/ })).toBeVisible();
await expect.element(screen.getByText("Lineage · 2 running")).toBeVisible();
await expect
.element(screen.getByRole("listitem").filter({ hasText: "Research lighting" }))
.toHaveClass("ms-4");
},
);

it.each([1, 18])("continues scrolling the panel past a list of %i subagents", async (count) => {
setup();
const projection = makeThreadProjectionFixture();
Expand Down
Loading
Loading