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
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const STATUS_LABEL_BY_STATUS: Partial<
approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" },
input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" },
working: { label: "Working", className: "text-sky-600 dark:text-sky-400" },
// Colorless like the web sidebar: parked on background work, not "act now".
waiting: { label: "Waiting", className: "text-foreground-tertiary" },
failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
};

Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ describe("resolveThreadListV2Status", () => {
expect(resolveThreadListV2Status(thread)).toBe("approval");
});

it("reports waiting when presentation parks runtime idle for background tasks", () => {
expect(
resolveThreadListV2Status(
makeThread({
id: ThreadId.make("t"),
title: "t",
pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }],
runtime: {
status: "idle",
activeRunId: null,
providerInstanceId: ProviderInstanceId.make("codex"),
providerName: "Codex",
lastError: null,
updatedAt: NOW,
},
}),
),
).toBe("waiting");
});

it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ export { snoozeWakeLabel };
* Thread List v2 model, ported from the web sidebar v2
* (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
*
* Four visual states, three colors: color is reserved for "act now"
* (approval), "in motion" (working), and "broken" (failed). Ready is the
* unlabeled resting state.
* Six visual states. Color distinguishes approval, input, active work, and
* failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
* parked on open background tasks, grey like working rather than a false Done.
* The orchestrator v2 presentation bridge parks runtime at idle when the
* post-settlement background roster is nonempty.
*/
export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";

export function resolveThreadListV2SnoozeMenuSelection(input: {
Expand Down Expand Up @@ -157,6 +159,9 @@ export function resolveThreadListV2Status(
) {
return "working";
}
if (thread.runtime?.status === "idle") {
return "waiting";
}
if (thread.runtime?.status === "failed") {
return "failed";
}
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
import {
deriveThreadActivityRun,
deriveThreadRuntime,
threadRuntimeHasInterruptibleRun,
} from "@t3tools/client-runtime/state/thread-execution";
import { useCallback, useEffect, useMemo } from "react";

Expand Down Expand Up @@ -150,7 +151,9 @@ export function useThreadComposerState() {
}, [selectedThreadActivityRun, selectedThreadSessionActivity, selectedThreadShell]);

const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime);
const interruptibleRunId = selectedThreadRuntime?.activeRunId ?? null;
const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime)
? (selectedThreadRuntime?.activeRunId ?? null)
: null;

const onSendMessage = useCallback(async () => {
if (!selectedThreadShell) {
Expand Down
307 changes: 307 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import {
EnvironmentId,
NodeId,
type OrchestrationV2ThreadProjection,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
RunId,
ThreadId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { expect, it } from "vite-plus/test";

import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
import {
layer as orchestratorMcpServiceLayer,
OrchestratorMcpService,
} from "./OrchestratorMcpService.ts";

const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail");
const projectId = ProjectId.make("project-mcp-orchestrator-detail");
const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent");
const childThreadId = ThreadId.make("thread-mcp-orchestrator-child");
const activeRunId = RunId.make("run-mcp-active");
const cancelledRunId = RunId.make("run-mcp-cancelled");
const childRunId = RunId.make("run-mcp-child");
const taskId = NodeId.make("node-mcp-task-1");
const now = DateTime.makeUnsafe("2026-08-04T12:00:00.000Z");
const codexDriver = ProviderDriverKind.make("codex");
// Distinct from driver kind so a regression that re-derives from driver fails.
const customCodexInstanceId = ProviderInstanceId.make("codex-custom-workspace");
const parentInstanceId = ProviderInstanceId.make("codex");

const makeScope = (): McpInvocationContext.McpInvocationScope => ({
environmentId,
threadId: parentThreadId,
providerSessionId: "provider-session-mcp-orchestrator-detail",
providerInstanceId: parentInstanceId,
capabilities: new Set(["orchestration"]),
issuedAt: 1,
});

function baseThread(input: {
readonly threadId: ThreadId;
readonly title: string;
readonly instanceId: ProviderInstanceId;
readonly model: string;
}) {
return {
id: input.threadId,
projectId,
title: input.title,
createdBy: "user" as const,
creationSource: "mcp" as const,
modelSelection: {
instanceId: input.instanceId,
model: input.model,
},
runtimeMode: "full-access" as const,
interactionMode: "default" as const,
branch: null,
worktreePath: null,
lineage: {
parentThreadId: null,
relationshipToParent: null,
rootThreadId: input.threadId,
},
archivedAt: null,
deletedAt: null,
providerInstanceId: input.instanceId,
createdAt: now,
updatedAt: now,
};
}

function makeRun(input: {
readonly id: RunId;
readonly ordinal: number;
readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed";
readonly instanceId?: ProviderInstanceId;
}) {
return {
id: input.id,
ordinal: input.ordinal,
status: input.status,
modelSelection: {
instanceId: input.instanceId ?? parentInstanceId,
model: "gpt-5.4",
},
providerInstanceId: input.instanceId ?? parentInstanceId,
requestedAt: now,
startedAt: input.status === "cancelled" || input.status === "queued" ? null : now,
completedAt: input.status === "cancelled" || input.status === "completed" ? now : null,
};
}

it("readThread prefers activity-run status over a newer cancelled queued run", async () => {
const projection = {
thread: baseThread({
threadId: parentThreadId,
title: "Parent",
instanceId: parentInstanceId,
model: "gpt-5.4",
}),
runs: [
makeRun({ id: activeRunId, ordinal: 1, status: "running" }),
makeRun({ id: cancelledRunId, ordinal: 2, status: "cancelled" }),
],
visibleTurnItems: [],
runtimeRequests: [],
messages: [],
contextTransfers: [],
subagents: [],
updatedAt: now,
} as unknown as OrchestrationV2ThreadProjection;

const layer = orchestratorMcpServiceLayer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagementService)({
getThreadProjection: (threadId) =>
threadId === parentThreadId
? Effect.succeed(projection)
: Effect.die(`unexpected thread ${threadId}`),
} satisfies Partial<ThreadManagementService["Service"]>),
Layer.mock(ProviderRegistry)({
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry["Service"]>),
Layer.mock(ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService["Service"]>),
NodeCrypto.layer,
),
),
);

await Effect.gen(function* () {
const service = yield* OrchestratorMcpService;
const result = yield* service.readThread(makeScope(), { threadId: parentThreadId });
expect(result.thread.status).toBe("running");
expect(result.thread.latestRunId).toBe(cancelledRunId);
expect(result.thread.activeRunId).toBe(activeRunId);
}).pipe(Effect.provide(layer), Effect.runPromise);
});

it("readThread prefers waiting activity status over a newer cancelled queued run", async () => {
const projection = {
thread: baseThread({
threadId: parentThreadId,
title: "Parent waiting",
instanceId: parentInstanceId,
model: "gpt-5.4",
}),
runs: [
makeRun({ id: activeRunId, ordinal: 1, status: "waiting" }),
makeRun({ id: cancelledRunId, ordinal: 2, status: "cancelled" }),
],
visibleTurnItems: [],
runtimeRequests: [],
messages: [],
contextTransfers: [],
subagents: [],
updatedAt: now,
} as unknown as OrchestrationV2ThreadProjection;

const layer = orchestratorMcpServiceLayer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagementService)({
getThreadProjection: (threadId) =>
threadId === parentThreadId
? Effect.succeed(projection)
: Effect.die(`unexpected thread ${threadId}`),
} satisfies Partial<ThreadManagementService["Service"]>),
Layer.mock(ProviderRegistry)({
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry["Service"]>),
Layer.mock(ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService["Service"]>),
NodeCrypto.layer,
),
),
);

await Effect.gen(function* () {
const service = yield* OrchestratorMcpService;
const result = yield* service.readThread(makeScope(), { threadId: parentThreadId });
expect(result.thread.status).toBe("waiting");
expect(result.thread.activeRunId).toBe(activeRunId);
}).pipe(Effect.provide(layer), Effect.runPromise);
});

it("taskStatus returns task.providerInstanceId rather than the driver kind", async () => {
const parentProjection = {
thread: baseThread({
threadId: parentThreadId,
title: "Parent",
instanceId: parentInstanceId,
model: "gpt-5.4",
}),
runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })],
visibleTurnItems: [],
runtimeRequests: [],
messages: [],
contextTransfers: [],
subagents: [
{
id: taskId,
threadId: parentThreadId,
runId: activeRunId,
parentNodeId: NodeId.make("node-parent"),
origin: "app_owned",
createdBy: "agent",
driver: codexDriver,
providerInstanceId: customCodexInstanceId,
providerThreadId: null,
childThreadId,
nativeTaskRef: null,
prompt: "Inspect the custom instance.",
title: null,
model: "gpt-5.4",
status: "running",
result: null,
startedAt: now,
completedAt: null,
updatedAt: now,
},
],
updatedAt: now,
} as unknown as OrchestrationV2ThreadProjection;

const childProjection = {
thread: {
...baseThread({
threadId: childThreadId,
title: "Child",
instanceId: customCodexInstanceId,
model: "gpt-5.4",
}),
lineage: {
parentThreadId,
relationshipToParent: "subagent",
rootThreadId: parentThreadId,
},
createdBy: "agent",
},
runs: [
makeRun({
id: childRunId,
ordinal: 1,
status: "running",
instanceId: customCodexInstanceId,
}),
],
visibleTurnItems: [],
runtimeRequests: [],
messages: [],
contextTransfers: [
{
type: "subagent_spawn",
sourceThreadId: parentThreadId,
targetThreadId: childThreadId,
targetRunId: childRunId,
},
],
subagents: [],
updatedAt: now,
} as unknown as OrchestrationV2ThreadProjection;

const layer = orchestratorMcpServiceLayer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagementService)({
getThreadProjection: (threadId) => {
if (threadId === parentThreadId) return Effect.succeed(parentProjection);
if (threadId === childThreadId) return Effect.succeed(childProjection);
return Effect.die(`unexpected thread ${threadId}`);
},
} satisfies Partial<ThreadManagementService["Service"]>),
Layer.mock(ProviderRegistry)({
getProviders: Effect.succeed([]),
} satisfies Partial<ProviderRegistry["Service"]>),
Layer.mock(ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService["Service"]>),
NodeCrypto.layer,
),
),
);

await Effect.gen(function* () {
const service = yield* OrchestratorMcpService;
const result = yield* service.taskStatus(makeScope(), taskId);
expect(result.providerInstanceId).toBe(customCodexInstanceId);
expect(result.providerInstanceId).not.toBe(ProviderInstanceId.make(String(codexDriver)));
expect(result.status).toBe("running");
expect(result.taskId).toBe(taskId);
expect(result.childThreadId).toBe(childThreadId);
}).pipe(Effect.provide(layer), Effect.runPromise);
});
Loading
Loading