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
140 changes: 140 additions & 0 deletions apps/server/src/mcp/toolkits/threads/mobileShell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
AuthSessionId,
ProjectId,
ProviderInstanceId,
ThreadId,
type OrchestrationShellStreamItem,
type OrchestrationThreadShell,
} from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";

import * as AuthSessions from "../../../persistence/AuthSessions.ts";
import { SqlitePersistenceMemory } from "../../../persistence/Layers/Sqlite.ts";
import { makeSessionClientSurface, shellStreamFor } from "./mobileShell.ts";

const now = "2026-01-01T00:00:00.000Z";
const userThreadId = ThreadId.make("thread-user");
const childThreadId = ThreadId.make("sub.thread-user.child1");

const threadShell = (id: ThreadId): OrchestrationThreadShell => ({
id,
projectId: ProjectId.make("project-1"),
title: "Thread",
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
pullRequests: [],
latestTurn: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
});

const items: ReadonlyArray<OrchestrationShellStreamItem> = [
{
kind: "snapshot",
snapshot: {
snapshotSequence: 1,
projects: [],
threads: [threadShell(userThreadId), threadShell(childThreadId)],
updatedAt: now,
},
},
{ kind: "synchronized" },
{ kind: "thread-upserted", sequence: 2, thread: threadShell(childThreadId) },
{ kind: "thread-upserted", sequence: 3, thread: threadShell(userThreadId) },
{ kind: "thread-removed", sequence: 4, threadId: childThreadId },
{ kind: "thread-removed", sequence: 5, threadId: userThreadId },
];

const threadIdsSeen = (surface: string | null) =>
Stream.fromIterable(items).pipe(
shellStreamFor(surface),
Stream.runCollect,
Effect.map((collected) =>
Array.from(collected).flatMap((item) => {
switch (item.kind) {
case "snapshot":
return item.snapshot.threads.map((thread) => `snapshot:${thread.id}`);
case "thread-upserted":
return [`upserted:${item.thread.id}`];
case "thread-removed":
return [`removed:${item.threadId}`];
default:
return [item.kind];
}
}),
),
);

describe("mobile shell", () => {
it.effect("drops child threads from mobile shell streams", () =>
Effect.gen(function* () {
assert.deepEqual(yield* threadIdsSeen("mobile"), [
`snapshot:${userThreadId}`,
"synchronized",
`upserted:${userThreadId}`,
`removed:${userThreadId}`,
]);
}),
);

it.effect("keeps child threads for web, desktop and unknown clients", () =>
Effect.gen(function* () {
const everything = [
`snapshot:${userThreadId}`,
`snapshot:${childThreadId}`,
"synchronized",
`upserted:${childThreadId}`,
`upserted:${userThreadId}`,
`removed:${childThreadId}`,
`removed:${userThreadId}`,
];
assert.deepEqual(yield* threadIdsSeen("web"), everything);
assert.deepEqual(yield* threadIdsSeen("desktop"), everything);
assert.deepEqual(yield* threadIdsSeen(null), everything);
}),
);

it.effect("reads the surface a session last connected with", () =>
Effect.gen(function* () {
const sessions = yield* AuthSessions.AuthSessionRepository;
const sessionClientSurface = yield* makeSessionClientSurface;
const sessionId = AuthSessionId.make("session-phone");
yield* sessions.create({
sessionId,
subject: "phone",
scopes: ["access:read"],
method: "bearer-access-token",
client: {
label: null,
ipAddress: null,
userAgent: null,
deviceType: "unknown",
os: null,
browser: null,
},
issuedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
expiresAt: DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"),
});

assert.equal(yield* sessionClientSurface(sessionId), null);
yield* sessions.setClientConnection({ sessionId, surface: "mobile", appVersion: null });
assert.equal(yield* sessionClientSurface(sessionId), "mobile");
assert.equal(yield* sessionClientSurface("session-unknown"), null);
}).pipe(Effect.provide(AuthSessions.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)))),
);
});
63 changes: 63 additions & 0 deletions apps/server/src/mcp/toolkits/threads/mobileShell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Child threads on mobile (toolboxmd/chromeria#59): the store mobile app has
* no Agents panel, so every child thread would land in its thread list. Mobile
* connections get the shell without child threads; web and desktop keep them
* for the Agents panel. Upstream Orchestrator V2 hides subagent threads on
* mobile natively, so this goes away when the fork moves to V2.
*/
import type { OrchestrationShellSnapshot, OrchestrationShellStreamItem } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { isSubagentThreadId } from "./subagentThreadId.ts";

export const hidesChildThreads = (surface: string | null | undefined) => surface === "mobile";

export const withoutChildThreads = (
snapshot: OrchestrationShellSnapshot,
): OrchestrationShellSnapshot => ({
...snapshot,
threads: snapshot.threads.filter((thread) => !isSubagentThreadId(thread.id)),
});

const isChildThreadEvent = (item: OrchestrationShellStreamItem) =>
(item.kind === "thread-upserted" && isSubagentThreadId(item.thread.id)) ||
(item.kind === "thread-removed" && isSubagentThreadId(item.threadId));

export const withoutChildThreadItems = <A extends OrchestrationShellStreamItem, E, R>(
stream: Stream.Stream<A, E, R>,
): Stream.Stream<A, E, R> =>
stream.pipe(
Stream.filter((item) => !isChildThreadEvent(item)),
Stream.map((item) =>
item.kind === "snapshot" ? { ...item, snapshot: withoutChildThreads(item.snapshot) } : item,
),
);

/** A shell snapshot as a client on `surface` should see it. */
export const shellSnapshotFor =
(surface: string | null | undefined) => (snapshot: OrchestrationShellSnapshot) =>
hidesChildThreads(surface) ? withoutChildThreads(snapshot) : snapshot;

/** A `subscribeShell` stream as a client on `surface` should see it. */
export const shellStreamFor =
(surface: string | null | undefined) =>
<A extends OrchestrationShellStreamItem, E, R>(stream: Stream.Stream<A, E, R>) =>
hidesChildThreads(surface) ? withoutChildThreadItems(stream) : stream;

/**
* Looks up the surface an auth session's client reported on its last WebSocket
* connect. HTTP requests carry no surface, but clients connect the socket
* before they load the shell over HTTP. Null when unknown.
*/
export const makeSessionClientSurface = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
return (sessionId: string) =>
sql<{ readonly surface: string | null }>`
SELECT client_surface AS surface FROM auth_sessions WHERE session_id = ${sessionId}
`.pipe(
Effect.map((rows) => rows[0]?.surface ?? null),
Effect.orElseSucceed(() => null),
);
});
18 changes: 10 additions & 8 deletions apps/server/src/orchestration/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
failEnvironmentNotFound,
requireEnvironmentScope,
} from "../auth/http.ts";
import { makeSessionClientSurface, shellSnapshotFor } from "../mcp/toolkits/threads/mobileShell.ts";
import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
Expand All @@ -27,6 +28,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
const orchestrationEngine = yield* OrchestrationEngineService;
const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
const sessionClientSurface = yield* makeSessionClientSurface;

return handlers
.handle(
Expand All @@ -52,14 +54,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
"shellSnapshot",
Effect.fn("environment.orchestration.shellSnapshot")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthOrchestrationReadScope);
return yield* projectionSnapshotQuery
.getShellSnapshot()
.pipe(
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_snapshot_failed", cause),
),
);
const session = yield* requireEnvironmentScope(AuthOrchestrationReadScope);
const surface = yield* sessionClientSurface(session.sessionId);
return yield* projectionSnapshotQuery.getShellSnapshot().pipe(
Effect.map(shellSnapshotFor(surface)),
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_snapshot_failed", cause),
),
);
}),
)
.handle(
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9041,6 +9041,53 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

// Fork: toolboxmd/chromeria#59
it.effect("hides child threads from mobile shell subscriptions only", () =>
Effect.gen(function* () {
const userThreadId = ThreadId.make("thread-user");
const childThreadId = ThreadId.make("sub.thread-user.child1");
yield* buildAppUnderTest({
layers: {
projectionSnapshotQuery: {
getShellSnapshot: () =>
Effect.succeed({
snapshotSequence: 1,
projects: [],
threads: [
makeDefaultOrchestrationThreadShell({ id: userThreadId }),
makeDefaultOrchestrationThreadShell({ id: childThreadId }),
],
updatedAt: "2026-01-01T00:00:00.000Z",
}),
},
},
});

const snapshotThreadIds = (path: string) =>
Effect.gen(function* () {
const wsUrl = yield* getWsServerUrl(path);
const items = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(
Stream.take(1),
Stream.runCollect,
),
),
);
const [first] = Array.from(items);
return first?.kind === "snapshot"
? first.snapshot.threads.map((thread) => thread.id)
: [];
});

assert.deepEqual(yield* snapshotThreadIds("/ws?clientSurface=mobile"), [userThreadId]);
assert.deepEqual(yield* snapshotThreadIds("/ws?clientSurface=web"), [
userThreadId,
childThreadId,
]);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("marks an empty shell catch-up replay as synchronized when requested", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import * as ServerSettings from "./serverSettings.ts";
import * as TerminalManager from "./terminal/Manager.ts";
import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
import { shellSnapshotFor, shellStreamFor } from "./mcp/toolkits/threads/mobileShell.ts";
import * as DeviceService from "./device/DeviceService.ts";
import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
import * as PreviewManager from "./preview/Manager.ts";
Expand Down Expand Up @@ -2171,13 +2172,14 @@ const makeWsRpcLayer = (
}),
synchronizedThenLive,
);
}),
}).pipe(Effect.map(shellStreamFor(clientOrigin.surface))),
{ "rpc.aggregate": "orchestration" },
),
[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) =>
observeRpcEffect(
ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
projectionSnapshotQuery.getArchivedShellSnapshot().pipe(
Effect.map(shellSnapshotFor(clientOrigin.surface)),
Effect.tapError((cause) =>
Effect.logError("orchestration archived shell snapshot load failed", { cause }),
),
Expand Down
20 changes: 16 additions & 4 deletions docs/fork-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,23 @@ for per-match decisions and the absorption PR record.
```json
{
"id": "child-threads",
"purpose": "Spawn child threads, hide them from sidebars and open them from their parent.",
"purpose": "Spawn child threads, hide them from sidebars and mobile, and open them from their parent.",
"issues": [
"https://github.com/toolboxmd/t3code/issues/8",
"https://github.com/toolboxmd/t3code/issues/48"
"https://github.com/toolboxmd/t3code/issues/48",
"https://github.com/toolboxmd/t3code/issues/59"
],
"prs": [
"https://github.com/toolboxmd/t3code/pull/10",
"https://github.com/toolboxmd/t3code/pull/60"
],
"prs": ["https://github.com/toolboxmd/t3code/pull/10"],
"newFiles": [
"apps/server/src/mcp/toolkits/threads/childReportState.test.ts",
"apps/server/src/mcp/toolkits/threads/childReportState.ts",
"apps/server/src/mcp/toolkits/threads/childThreads.test.ts",
"apps/server/src/mcp/toolkits/threads/handlers.ts",
"apps/server/src/mcp/toolkits/threads/mobileShell.test.ts",
"apps/server/src/mcp/toolkits/threads/mobileShell.ts",
"apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts",
"apps/server/src/mcp/toolkits/threads/subagentThreadId.ts",
"apps/server/src/mcp/toolkits/threads/tools.ts",
Expand All @@ -132,10 +138,16 @@ for per-match decisions and the absorption PR record.
"upstreamFiles": [
"apps/server/src/entrypoint.test.ts",
"apps/server/src/mcp/McpHttpServer.ts",
"apps/server/src/orchestration/http.ts",
"apps/server/src/server.test.ts",
"apps/web/src/components/LegacySidebar.tsx",
"apps/web/src/components/Sidebar.tsx"
],
"sharedFiles": ["scripts/build-desktop-artifact.ts", "apps/web/src/components/AgentsPanel.tsx"],
"sharedFiles": [
"scripts/build-desktop-artifact.ts",
"apps/web/src/components/AgentsPanel.tsx",
"apps/server/src/ws.ts"
],
"keywords": ["parentThreadId", "child thread", "subagent", "spawn_thread", "sidebar"]
}
```
Expand Down
4 changes: 4 additions & 0 deletions scripts/fork-upstream-edits.txt
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,7 @@ apps/server/src/provider/RuntimeInstructions.ts
# Prism stream clock: hide per-turn stream statistics from work logs (#55)
apps/mobile/src/lib/threadActivity.ts
apps/web/src/session-logic.ts

# Child threads hidden from mobile shells (#59)
apps/server/src/orchestration/http.ts
apps/server/src/server.test.ts
Loading