Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ export function ThreadRelationshipsBanner(props: {
const rows = useMemo(
() =>
copySorted(
immediateThreadRelationships(graph, props.threadId),
// Subagent edges come from the projection, not from thread shells, so
// they survive the shell filtering and would point at threads the
// client no longer receives — rendering as "Unavailable", and taking
// over the banner headline when they sort first.
immediateThreadRelationships(graph, props.threadId).filter(
(relationship) => relationship.edge.kind !== "subagent",
),
(left, right) =>
Number(right.threadId === mergeTargetThreadId) -
Number(left.threadId === mergeTargetThreadId),
Expand Down
9 changes: 0 additions & 9 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { T3_CODE_BRAND_MARK_SOURCE } from "../../components/brandAssets";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import Animated, { FadeIn } from "react-native-reanimated";
import { useV2ItemSupport } from "../../state/v2-item-support";
import { ThreadActivityInspector } from "./ThreadActivityInspector";

const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
Expand Down Expand Up @@ -112,11 +111,6 @@ function ThreadActivityThreadLink(props: {
readonly iconColor: import("react-native").ColorValue;
}) {
const row = props.activity.projectedItem;
const support = useV2ItemSupport({
environmentId: props.environmentId,
sourceThreadId: row.sourceThreadId,
sourceItemId: row.sourceItemId,
});
const navigation = useNavigation();
const item = row.item;
let targetThreadId: ThreadId | null = null;
Expand All @@ -125,9 +119,6 @@ function ThreadActivityThreadLink(props: {
if (item.type === "thread_created") {
targetThreadId = item.targetThreadId;
label = "Open created thread";
} else if (item.type === "subagent") {
targetThreadId = support.subagent?.childThreadId ?? item.childThreadId;
label = "Open subagent thread";
} else if (item.type === "fork") {
targetThreadId =
item.targetThreadId === row.sourceThreadId && item.source.type === "run"
Expand Down
63 changes: 62 additions & 1 deletion apps/server/src/orchestration-v2/ThreadManagementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import {
CommandId,
NodeId,
type OrchestrationV2Command,
type OrchestrationV2ThreadShell,
ProjectId,
ProviderInstanceId,
RunId,
ThreadId,
} from "@t3tools/contracts";

import { existingThreadIdsForCommand, withCreationProvenance } from "./ThreadManagementService.ts";
import {
existingThreadIdsForCommand,
userFacingShellSnapshot,
withCreationProvenance,
} from "./ThreadManagementService.ts";

it("stamps authoritative provenance on commands that create threads or messages", () => {
const command: OrchestrationV2Command = {
Expand Down Expand Up @@ -161,3 +166,59 @@ it("identifies every existing thread that must be hydrated before dispatch", ()
}),
).toEqual([parentThreadId, targetThreadId]);
});

it("removes internal subagent children from active and archived shell collections", () => {
const rootId = ThreadId.make("thread:thread-management:root");
const forkId = ThreadId.make("thread:thread-management:fork");
const lineageSubagentId = ThreadId.make("thread:thread-management:lineage-subagent");
const nodeSubagentId = ThreadId.make("thread:thread-management:node-subagent");
const shell = (
id: ThreadId,
lineage: OrchestrationV2ThreadShell["lineage"],
forkedFrom: OrchestrationV2ThreadShell["forkedFrom"],
) =>
({
id,
lineage,
forkedFrom,
}) as OrchestrationV2ThreadShell;
const rootLineage = {
rootThreadId: rootId,
parentThreadId: null,
relationshipToParent: null,
} as const;
const snapshot = userFacingShellSnapshot({
schemaVersion: 3,
snapshotSequence: 10,
threads: [
shell(rootId, rootLineage, null),
shell(
forkId,
{
rootThreadId: rootId,
parentThreadId: rootId,
relationshipToParent: "fork",
},
{ type: "run", threadId: rootId, runId: RunId.make("run:thread-management:fork") },
),
shell(
lineageSubagentId,
{
rootThreadId: rootId,
parentThreadId: rootId,
relationshipToParent: "subagent",
},
null,
),
],
archivedThreads: [
shell(nodeSubagentId, rootLineage, {
type: "node",
nodeId: NodeId.make("node:thread-management:subagent"),
}),
],
});

expect(snapshot.threads.map((thread) => thread.id)).toEqual([rootId, forkId]);
expect(snapshot.archivedThreads).toEqual([]);
});
15 changes: 11 additions & 4 deletions apps/server/src/orchestration-v2/ThreadManagementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ export class ThreadManagementService extends Context.Service<
ThreadManagementServiceShape
>()("t3/orchestration-v2/ThreadManagementService") {}

export const isInternalSubagentThread = (
thread: Pick<OrchestrationV2ThreadShell, "forkedFrom" | "lineage">,
) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node";

export const userFacingShellSnapshot = (snapshot: OrchestrationV2ThreadShellSnapshot) => ({
...snapshot,
threads: snapshot.threads.filter((thread) => !isInternalSubagentThread(thread)),
archivedThreads: snapshot.archivedThreads.filter((thread) => !isInternalSubagentThread(thread)),
});

export function isActiveRun(run: OrchestrationV2Run): boolean {
return (
run.status === "preparing" ||
Expand Down Expand Up @@ -359,10 +369,7 @@ const make = Effect.gen(function* () {
Effect.map((snapshot) =>
snapshot.threads
.filter((thread) => thread.projectId === input.projectId)
.filter(
(thread) =>
input.includeSubagents || thread.lineage.relationshipToParent !== "subagent",
)
.filter((thread) => input.includeSubagents || !isInternalSubagentThread(thread))
.toSorted(
(left, right) =>
DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) ||
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/orchestration-v2/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
const base = yield* sql.withTransaction(
Effect.gen(function* () {
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
const threads = yield* threadManagement.getShellSnapshot();
const threads = yield* threadManagement
.getShellSnapshot()
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
return {
schemaVersion: threads.schemaVersion,
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
const shell = yield* threads.getShellSnapshot();
const existingThread = shell.threads.find(
(thread) =>
thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent",
thread.projectId === project.id && !ThreadManagement.isInternalSubagentThread(thread),
);
if (existingThread === undefined) {
const launched = yield* threadLaunch.launch({
Expand Down
16 changes: 13 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,9 @@ const makeWsRpcLayer = (
const base = yield* sql.withTransaction(
Effect.gen(function* () {
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
const threads = yield* threadManagement.getShellSnapshot();
const threads = yield* threadManagement
.getShellSnapshot()
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
return {
schemaVersion: threads.schemaVersion,
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
Expand Down Expand Up @@ -819,7 +821,12 @@ const makeWsRpcLayer = (
const survivors = coalesceShellApplicationEvents(events);
const needsThreadSnapshot = survivors.some((stored) => !("aggregateKind" in stored));
const threadSnapshot = needsThreadSnapshot
? yield* threadManagement.getShellSnapshot().pipe(Effect.map(Option.some))
? yield* threadManagement
.getShellSnapshot()
.pipe(
Effect.map(ThreadManagementService.userFacingShellSnapshot),
Effect.map(Option.some),
)
: Option.none();
return yield* Effect.forEach(
survivors,
Expand Down Expand Up @@ -955,7 +962,9 @@ const makeWsRpcLayer = (
.withTransaction(
Effect.gen(function* () {
const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment();
const threads = yield* threadManagement.getShellSnapshot();
const threads = yield* threadManagement
.getShellSnapshot()
.pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot));
return {
schemaVersion: threads.schemaVersion,
snapshotSequence: yield* applicationEvents.latestApplicationSequence,
Expand Down Expand Up @@ -988,6 +997,7 @@ const makeWsRpcLayer = (
.pipe(
Stream.mapEffect((stored) =>
threadManagement.getShellSnapshot().pipe(
Effect.map(ThreadManagementService.userFacingShellSnapshot),
Effect.map((nextSnapshot) =>
archivedShellStreamItemFromSnapshot({ stored, snapshot: nextSnapshot }),
),
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ import {
sortProjectsForSidebar,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
} from "./Sidebar.logic";
import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
import {
EnvironmentId,
NodeId,
ProjectId,
ProviderInstanceId,
RunId,
ThreadId,
} from "@t3tools/contracts";
import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
Expand Down Expand Up @@ -260,6 +267,13 @@ describe("sidebar thread lineage helpers", () => {
});

expect(isSidebarSubagentThread(subagent)).toBe(true);
expect(
isSidebarSubagentThread(
makeThreadFixture({
forkedFrom: { type: "node", nodeId: NodeId.make("node-provider-subagent") },
}),
),
).toBe(true);
expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false);
});

Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from "react";
import type { ContextMenuItem } from "@t3tools/contracts";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import { isInternalSubagentThread as isSidebarSubagentThread } from "../threadVisibility";

export { isSidebarSubagentThread };
import {
getThreadSortTimestamp,
sortThreads,
Expand Down Expand Up @@ -92,12 +95,8 @@ export function buildMultiSelectThreadContextMenuItems(input: {
];
}

export function isSidebarSubagentThread(thread: Pick<SidebarThreadSummary, "lineage">): boolean {
return thread.lineage.relationshipToParent === "subagent";
}

export function filterSidebarV2VisibleThreads<
T extends Pick<SidebarThreadSummary, "archivedAt" | "lineage"> & {
T extends Pick<SidebarThreadSummary, "archivedAt" | "forkedFrom" | "lineage"> & {
environmentId: string;
projectId: string;
},
Expand Down Expand Up @@ -832,7 +831,7 @@ export function sortLogicalProjectsForSidebar<

export function sortSidebarV2ProjectGroups<
TProject extends LogicalSidebarProject,
TThread extends ScopedSidebarThread & Pick<SidebarThreadSummary, "lineage">,
TThread extends ScopedSidebarThread & Pick<SidebarThreadSummary, "forkedFrom" | "lineage">,
>(
projects: readonly TProject[],
threads: readonly TThread[],
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,7 @@ describe("MessagesTimeline", () => {
);

expect(markup).toContain('data-v2-item-type="subagent"');
expect(markup).toContain('aria-label="Open Package audit"');
expect(markup).not.toContain('aria-label="Open Package audit"');
expect(markup).toContain("Reading src/index.ts");
expect(markup).not.toContain("Inspect the package");
expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
Expand Down Expand Up @@ -908,7 +908,7 @@ describe("MessagesTimeline", () => {
expect(markup).toContain('data-v2-subagent-result-disclosure="true"');
expect(markup).toContain('data-v2-subagent-result="true"');
expect(markup).toContain('aria-label="Show full result for Isolation report"');
expect(markup).toContain('aria-label="Open Isolation report"');
expect(markup).not.toContain('aria-label="Open Isolation report"');
expect(markup).toContain("Tests should be isolated.");
expect(markup).toContain("Result: no shared state.");
expect(markup).not.toContain("Explain test isolation");
Expand Down Expand Up @@ -961,7 +961,7 @@ describe("MessagesTimeline", () => {
);

expect(markup).toContain('data-v2-item-type="subagent"');
expect(markup).toContain('aria-label="Open Package audit"');
expect(markup).not.toContain('aria-label="Open Package audit"');
expect(markup).toContain("Reading src/index.ts");
expect(markup).not.toContain("Partial streamed answer so far");
expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const testState = vi.hoisted(() => ({
canDetach: false,
relationships: [] as ReadonlyArray<unknown>,
}));

vi.mock("@t3tools/client-runtime/state/thread-relationships", () => ({
deriveThreadRelationshipGraph: () => ({ nodes: new Map() }),
immediateThreadRelationships: () => testState.relationships,
resolveMergeBackTargetThreadId: () => null,
}));
vi.mock("@t3tools/client-runtime/state/thread-workflows", () => ({
canDetachThreadProviderSession: () => testState.canDetach,
}));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("../../lib/archivedThreadsState", () => ({
useArchivedThreadSnapshots: () => ({ snapshots: [] }),
}));
vi.mock("../../state/entities", () => ({
useThreadProjection: () => ({ projection: { runs: [] } }),
useThreadShells: () => [],
}));
vi.mock("../../state/threads", () => ({
threadEnvironment: {
mergeBack: Symbol("mergeBack"),
stopSession: Symbol("stopSession"),
},
}));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
vi.mock("../ui/menu", () => ({
Menu: ({ children }: { readonly children: ReactNode }) => <>{children}</>,
MenuTrigger: ({ children }: { readonly children: ReactNode }) => <>{children}</>,
MenuPopup: ({ children }: { readonly children: ReactNode }) => <>{children}</>,
MenuItem: ({ children }: { readonly children: ReactNode }) => <>{children}</>,
}));

import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";

const environmentId = "environment:relationships" as EnvironmentId;
const threadId = "thread:relationships" as ThreadId;
const childThreadId = "thread:relationships:subagent" as ThreadId;

const renderPanel = () =>
renderToStaticMarkup(
<ThreadRelationshipsPanel environmentId={environmentId} threadId={threadId} />,
);

describe("ThreadRelationshipsPanel", () => {
beforeEach(() => {
testState.canDetach = false;
testState.relationships = [
{
threadId: childThreadId,
edge: {
kind: "subagent",
sourceThreadId: threadId,
targetThreadId: childThreadId,
status: "running",
},
},
];
});

it("keeps disconnect controls when hidden subagent edges are the only relationships", () => {
testState.canDetach = true;

const markup = renderPanel();

expect(markup).toContain("Lineage");
expect(markup).toContain("Disconnect agent session");
expect(markup).not.toContain("Subagent");
});

it("renders nothing when no visible relationship or detachable session exists", () => {
expect(renderPanel()).toBe("");
});
});
Loading
Loading