diff --git a/apps/mobile/src/features/threads/thread-handoff-row.tsx b/apps/mobile/src/features/threads/thread-handoff-row.tsx
index 4328809c9c27..4007fe183dba 100644
--- a/apps/mobile/src/features/threads/thread-handoff-row.tsx
+++ b/apps/mobile/src/features/threads/thread-handoff-row.tsx
@@ -91,16 +91,24 @@ function HandoffEndpoint(props: {
Alert.alert(label)}
+ onPress={() =>
+ Alert.alert(
+ label,
+ provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId,
+ )
+ }
>
+
+ {label}
+
);
}
diff --git a/apps/mobile/src/features/threads/thread-provider-switching.test.ts b/apps/mobile/src/features/threads/thread-provider-switching.test.ts
index 1db00af52d82..950a4dd33685 100644
--- a/apps/mobile/src/features/threads/thread-provider-switching.test.ts
+++ b/apps/mobile/src/features/threads/thread-provider-switching.test.ts
@@ -55,6 +55,34 @@ describe("threadAllowsProviderSwitch", () => {
expect(threadAllowsProviderSwitch({ thread: unstartedThread, projection: null })).toBe(true);
});
+ it("allows an imported thread to hand off before opening a provider session", () => {
+ expect(
+ threadAllowsProviderSwitch({
+ thread: startedThread,
+ projection: {
+ thread: { id: "thread", activeProviderThreadId: null, historyOrigin: "v1_import" },
+ runs: [],
+ providerThreads: [],
+ providerSessions: [],
+ } as unknown as OrchestrationV2ThreadProjection,
+ }),
+ ).toBe(true);
+ });
+
+ it("keeps a preparing turn bound before its provider session appears", () => {
+ expect(
+ threadAllowsProviderSwitch({
+ thread: startedThread,
+ projection: {
+ thread: { id: "thread", activeProviderThreadId: null, historyOrigin: "v1_import" },
+ runs: [{ status: "preparing", providerThreadId: "provider-thread" }],
+ providerThreads: [],
+ providerSessions: [],
+ } as unknown as OrchestrationV2ThreadProjection,
+ }),
+ ).toBe(false);
+ });
+
it("keeps a started thread bound until its projection resolves a session", () => {
expect(threadAllowsProviderSwitch({ thread: startedThread, projection: null })).toBe(false);
});
diff --git a/apps/mobile/src/features/threads/thread-provider-switching.ts b/apps/mobile/src/features/threads/thread-provider-switching.ts
index 791350fd6c2e..8daa3636b3ea 100644
--- a/apps/mobile/src/features/threads/thread-provider-switching.ts
+++ b/apps/mobile/src/features/threads/thread-provider-switching.ts
@@ -1,5 +1,5 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
-import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
+import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";
type ThreadStartMarkers = Pick<
@@ -24,9 +24,7 @@ export function threadAllowsProviderSwitch(input: {
readonly thread: ThreadStartMarkers | null | undefined;
readonly projection: OrchestrationV2ThreadProjection | null | undefined;
}): boolean {
- const session = input.projection ? resolveThreadProviderSession(input.projection) : null;
- if (session !== null) {
- return session.capabilities.sessions.supportsProviderSwitchingViaHandoff;
- }
- return !threadShellHasStarted(input.thread);
+ return input.projection != null
+ ? threadSupportsProviderHandoff(input.projection)
+ : !threadShellHasStarted(input.thread);
}
diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts
index c487bdd10823..2f9f88db12f4 100644
--- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts
+++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts
@@ -105,6 +105,21 @@ it("resolves automatic message delivery from authoritative provider capabilities
);
});
+it.each(["preparing", "starting"] as const)(
+ "queues an automatic message while the handoff run is %s",
+ (status) => {
+ const projection = dispatchProjection(baseCapabilities);
+ assert.deepEqual(
+ resolveMessageDispatchIntent(
+ { ...projection, runs: projection.runs.map((run) => ({ ...run, status })) },
+ { type: "start_immediately" },
+ "auto",
+ ),
+ { type: "queue_after_active" },
+ );
+ },
+);
+
it("targets the latest active run for explicit steer and restart intent", () => {
const projection = dispatchProjection(baseCapabilities);
assert.deepEqual(
diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts
index bdd1845e3ba5..cc7b88e12767 100644
--- a/apps/server/src/orchestration-v2/CommandPolicy.ts
+++ b/apps/server/src/orchestration-v2/CommandPolicy.ts
@@ -137,6 +137,9 @@ export function resolveMessageDispatchIntent(
if (deliveryIntent === "restart") {
return { type: "restart_active", targetRunId: activeRun.id };
}
+ if (activeRun.status === "preparing" || activeRun.status === "starting") {
+ return { type: "queue_after_active" };
+ }
const providerThread = projection.providerThreads.find(
(candidate) => candidate.id === activeRun.providerThreadId,
diff --git a/apps/server/src/orchestration-v2/ProviderFailure.test.ts b/apps/server/src/orchestration-v2/ProviderFailure.test.ts
index 68cd5e2d227c..58c869297393 100644
--- a/apps/server/src/orchestration-v2/ProviderFailure.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderFailure.test.ts
@@ -7,6 +7,7 @@ import {
RunId,
ThreadId,
} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
@@ -66,6 +67,17 @@ it("does not split a surrogate pair at the truncation boundary", () => {
assert.notMatch(failure.message.slice(0, -1), /[\uD800-\uDBFF]$/u);
});
+it("does not expose error or Effect cause messages", () => {
+ const cause = new Error("private command output", { cause: new Error("private nested output") });
+ for (const value of [cause, Cause.fail(cause), "private string", { message: "private object" }]) {
+ assert.equal(makeProviderFailure({ cause: value }).message, "Provider turn failed.");
+ assert.equal(
+ makeProviderFailure({ cause: value, message: "Provider connection closed." }).message,
+ "Provider connection closed.",
+ );
+ }
+});
+
it("does not serialize arbitrary provider causes", () => {
const failure = makeProviderFailure({
cause: {
diff --git a/apps/server/src/orchestration-v2/ProviderFailure.ts b/apps/server/src/orchestration-v2/ProviderFailure.ts
index 31d27f7f43a9..a0e649d25ae9 100644
--- a/apps/server/src/orchestration-v2/ProviderFailure.ts
+++ b/apps/server/src/orchestration-v2/ProviderFailure.ts
@@ -95,10 +95,7 @@ export function makeProviderFailure(input: {
readonly class?: OrchestrationV2ProviderFailureClass;
readonly retryable?: boolean | null;
}): OrchestrationV2ProviderFailure {
- const rawMessage =
- input.message ??
- (input.cause instanceof Error ? input.cause.message : stringField(input.cause, "message")) ??
- DEFAULT_PROVIDER_FAILURE_MESSAGE;
+ const rawMessage = input.message ?? DEFAULT_PROVIDER_FAILURE_MESSAGE;
const message = boundedText(rawMessage, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH);
const rawCode = input.code ?? stringField(input.cause, "code") ?? null;
const code =
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
index 2636a61d0521..a34a5ffc1445 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
@@ -2939,6 +2939,8 @@ it.effect("refreshes pull requests after a provider stream exits with an error",
written.map((item) => item.type),
["error"],
);
+ const error = written.find((item) => item.type === "error");
+ assert.equal(error?.failure.message, "Provider turn failed.");
}),
);
@@ -3003,19 +3005,11 @@ function captureRootRunTermination(input: {
driver,
status: "running",
});
- const writtenItems = yield* Ref.make<
- ReadonlyArray<{ readonly type: string; readonly parentItemId: string | null }>
- >([]);
+ const writtenItems = yield* Ref.make>([]);
const observed = yield* Ref.make>([]);
const ingestionDone = yield* Deferred.make();
- const captureTurnItem = (payload: {
- readonly type: string;
- readonly parentItemId: string | null;
- }) =>
- Ref.update(writtenItems, (current) => [
- ...current,
- { type: payload.type, parentItemId: payload.parentItemId },
- ]);
+ const captureTurnItem = (payload: OrchestrationV2TurnItem) =>
+ Ref.update(writtenItems, (current) => [...current, payload]);
const testLayer = runExecutionServiceLayer.pipe(
Layer.provide(
Layer.mergeAll(
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 17c712ed3d04..c10edc5a6822 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -77,7 +77,7 @@ import {
deriveLatestThreadRun,
deriveThreadRuntime,
} from "@t3tools/client-runtime/state/thread-execution";
-import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
+import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import {
codexFeedbackMessage,
parseCodexFeedbackCommand,
@@ -1997,12 +1997,10 @@ export default function ChatView(props: ChatViewProps) {
() => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)),
[serverProjection],
);
- const activeProviderSession = useMemo(
- () => (serverProjection === null ? null : resolveThreadProviderSession(serverProjection)),
+ const supportsProviderSwitchingViaHandoff = useMemo(
+ () => threadSupportsProviderHandoff(serverProjection),
[serverProjection],
);
- const supportsProviderSwitchingViaHandoff =
- activeProviderSession?.capabilities.sessions.supportsProviderSwitchingViaHandoff === true;
const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null);
const activeActivityRun = isServerThread ? serverActivityRun : (activeThread?.latestRun ?? null);
const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null);
@@ -10247,6 +10245,12 @@ export default function ChatView(props: ChatViewProps) {
isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
listRef={legendListRef}
timelineEntries={displayedTimeline.entries}
+ providerStatuses={
+ environmentById.get(
+ displayedThreadRef?.environmentId ?? activeThread.environmentId,
+ )?.serverConfig?.providers ?? EMPTY_PROVIDERS
+ }
+ runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])}
latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun}
runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
turnDiffSummaries={
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 50146e17001d..6eeea5055847 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -322,12 +322,6 @@ function terminalProcessLabel(count: number): string {
return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
}
-// Trailing provider glyphs for a row. A thread that has been handed off
-// between providers draws its earlier owners behind the current one, so the
-// list shows where the thread has been without widening the row. Rendered
-// back to front so DOM order matches visual layering. No separator ring: row
-// surfaces vary (active, selected, draft, hover), so earlier glyphs are
-// shrunk and dimmed instead, which reads as depth on any background.
function SidebarProviderStack(props: {
thread: SidebarThreadSummary;
providerEntryByInstanceId: ReadonlyMap;
@@ -357,7 +351,7 @@ function SidebarProviderStack(props: {
return {current};
}
return (
-
+
{stack.slice(0, -1).map((instanceId) => {
const entry = props.providerEntryByInstanceId.get(instanceId);
if (entry === undefined) return null;
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index d4f9c6e7f358..b22dcaad14e8 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -229,6 +229,8 @@ function buildProps() {
listRef: createRef(),
latestRun: null,
turnDiffSummaries: [],
+ providerStatuses: [],
+ runs: [],
routeThreadKey: "environment-local:thread-1",
onOpenTurnDiff: () => {},
onOpenThread: () => {},
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index b084b6d17c3e..add5e83dbcd5 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -357,7 +357,6 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
layout: true,
},
} as const satisfies MaintainScrollAtEndOptions;
-const EMPTY_TIMELINE_PROVIDERS: ReadonlyArray = [];
const EMPTY_TIMELINE_RUNS: ReadonlyArray = [];
// Streamed text lands a paragraph at a time. A smooth scroll to the end
// turns each landing into a short glide instead of a jump. Thread switches
@@ -429,8 +428,8 @@ interface MessagesTimelineProps {
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray>;
- providerStatuses?: ReadonlyArray;
- runs?: ReadonlyArray;
+ providerStatuses: ReadonlyArray;
+ runs: ReadonlyArray;
anchorMessageId: MessageId | null;
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
@@ -500,8 +499,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
timestampFormat,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
- providerStatuses = EMPTY_TIMELINE_PROVIDERS,
- runs: runsProp = EMPTY_TIMELINE_RUNS,
+ providerStatuses,
+ runs: runsProp,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx
index d5a5823c0b91..b35c03e08617 100644
--- a/apps/web/src/components/chat/TimelineSystemDivider.tsx
+++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx
@@ -19,7 +19,12 @@ export function TimelineSystemDivider(props: {
{Icon ? : null}
{props.label}
{props.detail ? (
-
+
{props.showDetailSeparator === false ? null : "·\u00a0"}
{props.detail}
@@ -52,7 +57,9 @@ export function TimelineSystemDivider(props: {
{props.actionLabel}
) : (
- {content}
+
+ {content}
+
)}
diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx
index 5facfcc3675f..17a4efc9e85e 100644
--- a/apps/web/src/components/chat/V2LifecycleRow.tsx
+++ b/apps/web/src/components/chat/V2LifecycleRow.tsx
@@ -118,7 +118,7 @@ export function V2LifecycleRow(props: {
showDetailSeparator={false}
tone={item.status === "failed" ? "danger" : "neutral"}
detail={
-
+
{fromEndpoints.map((endpoint, index) => (
{index > 0 ? (
@@ -289,7 +289,6 @@ function SubagentTimelineLink(props: {
);
}
-/** Provider icon with the resolved handoff model available on hover or focus. */
function HandoffEndpoint(props: {
readonly providers: ReadonlyArray;
readonly instanceId: ProviderInstanceId;
@@ -313,9 +312,7 @@ function HandoffEndpoint(props: {
render={
+ {label}
}
/>
- {label}
+
+ {entry?.displayName ?? props.instanceId} · {label}
+
);
}
diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts
index 050b3167bd3c..e201e3c6f409 100644
--- a/packages/client-runtime/src/state/threadWorkflows.ts
+++ b/packages/client-runtime/src/state/threadWorkflows.ts
@@ -62,7 +62,7 @@ export function resolveLatestMergeBackRun(projection: Projection): Run | null {
return hasNewerActiveRun ? null : latestProviderFinishedRun;
}
-export function resolveThreadProviderSession(projection: Projection): ProviderSession | null {
+function resolveThreadProviderSession(projection: Projection): ProviderSession | null {
const activeRun = resolveActiveThreadRun(projection);
const providerThreadId = activeRun?.providerThreadId ?? projection.thread.activeProviderThreadId;
const activeProviderThread =
@@ -86,6 +86,18 @@ export function resolveThreadProviderSession(projection: Projection): ProviderSe
);
}
+export function threadSupportsProviderHandoff(projection: Projection | null | undefined): boolean {
+ if (projection == null) return false;
+ const session = resolveThreadProviderSession(projection);
+ if (session !== null) {
+ return session.capabilities.sessions.supportsProviderSwitchingViaHandoff;
+ }
+ return (
+ resolveActiveThreadRun(projection) === null &&
+ (projection.thread.historyOrigin === "v1_import" || projection.runs.length === 0)
+ );
+}
+
/** Automatic completion/notification runs are not messages in the user's queue. */
export function getUserQueuedThreadRuns(
projection: Pick,