From 9d623002aa5de824e50b946892a13a2e46a89379 Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Thu, 17 Sep 2026 16:24:10 +0200
Subject: [PATCH 1/6] fix: make provider handoffs readable and available after
import
---
.../src/features/threads/thread-handoff-row.tsx | 14 +++++++++++---
.../threads/thread-provider-switching.test.ts | 14 ++++++++++++++
.../features/threads/thread-provider-switching.ts | 10 ++++------
apps/web/src/components/ChatView.tsx | 14 +++++++++-----
apps/web/src/components/Sidebar.tsx | 8 +-------
.../src/components/chat/MessagesTimeline.test.tsx | 2 ++
apps/web/src/components/chat/MessagesTimeline.tsx | 9 ++++-----
.../src/components/chat/TimelineSystemDivider.tsx | 11 +++++++++--
apps/web/src/components/chat/V2LifecycleRow.tsx | 12 ++++++------
.../client-runtime/src/state/threadWorkflows.ts | 9 +++++++++
10 files changed, 69 insertions(+), 34 deletions(-)
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..63a14853b3eb 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,20 @@ 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 },
+ runs: [],
+ providerThreads: [],
+ providerSessions: [],
+ } as unknown as OrchestrationV2ThreadProjection,
+ }),
+ ).toBe(true);
+ });
+
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/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..9de671856eab 100644
--- a/packages/client-runtime/src/state/threadWorkflows.ts
+++ b/packages/client-runtime/src/state/threadWorkflows.ts
@@ -86,6 +86,15 @@ export function resolveThreadProviderSession(projection: Projection): ProviderSe
);
}
+export function threadSupportsProviderHandoff(projection: Projection | null | undefined): boolean {
+ return (
+ projection != null &&
+ (resolveThreadProviderSession(projection)?.capabilities.sessions
+ .supportsProviderSwitchingViaHandoff ??
+ true)
+ );
+}
+
/** Automatic completion/notification runs are not messages in the user's queue. */
export function getUserQueuedThreadRuns(
projection: Pick,
From cd0ade757023d235cc9143e8b55f2886ee74d765 Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Thu, 17 Sep 2026 16:24:53 +0200
Subject: [PATCH 2/6] fix(server): queue automatic messages while a provider
starts
---
.../src/orchestration-v2/CommandPolicy.test.ts | 15 +++++++++++++++
apps/server/src/orchestration-v2/CommandPolicy.ts | 3 +++
2 files changed, 18 insertions(+)
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,
From 642860586230c859e14cb162db27f647cca8172d Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Thu, 17 Sep 2026 16:30:50 +0200
Subject: [PATCH 3/6] fix(client): keep provider session lookup internal
---
packages/client-runtime/src/state/threadWorkflows.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts
index 9de671856eab..25aebab2f2cb 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 =
From 4a61a23d7c541cc3037e87655fa6f99b80b6c545 Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Thu, 17 Sep 2026 18:37:10 +0200
Subject: [PATCH 4/6] fix(server): retain nested provider failure messages
---
.../orchestration-v2/ProviderFailure.test.ts | 21 ++++++++++++++++++
.../src/orchestration-v2/ProviderFailure.ts | 22 +++++++++++++++----
.../RunExecutionService.test.ts | 16 +++++---------
3 files changed, 44 insertions(+), 15 deletions(-)
diff --git a/apps/server/src/orchestration-v2/ProviderFailure.test.ts b/apps/server/src/orchestration-v2/ProviderFailure.test.ts
index 68cd5e2d227c..0f8accf1abf6 100644
--- a/apps/server/src/orchestration-v2/ProviderFailure.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderFailure.test.ts
@@ -66,6 +66,27 @@ it("does not split a surrogate pair at the truncation boundary", () => {
assert.notMatch(failure.message.slice(0, -1), /[\uD800-\uDBFF]$/u);
});
+it("keeps nested error messages without exposing their stacks or credentials", () => {
+ const root = new Error("ACP rejected the event: api_key=nested-secret");
+ const failure = makeProviderFailure({
+ cause: new Error("Failed while ingesting orchestration V2 run execution.", {
+ cause: new Error("Failed to normalize provider event turn_item.updated.", { cause: root }),
+ }),
+ });
+
+ assert.include(failure.message, "Failed to normalize provider event turn_item.updated.");
+ assert.include(failure.message, "ACP rejected the event: api_key=[REDACTED]");
+ assert.notInclude(failure.message, "nested-secret");
+ assert.notInclude(failure.message, "ProviderFailure.test.ts");
+});
+
+it("stops at repeated errors in a cause chain", () => {
+ const cause = new Error("Repeated error");
+ cause.cause = cause;
+
+ assert.equal(makeProviderFailure({ cause }).message, "Repeated error");
+});
+
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..62220b670d2f 100644
--- a/apps/server/src/orchestration-v2/ProviderFailure.ts
+++ b/apps/server/src/orchestration-v2/ProviderFailure.ts
@@ -10,6 +10,7 @@ import type {
RunId,
ThreadId,
} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
import type * as DateTime from "effect/DateTime";
import type { IdAllocatorV2Shape } from "./IdAllocator.ts";
@@ -29,6 +30,22 @@ function stringField(value: unknown, key: "message" | "code"): string | undefine
}
}
+function causeMessage(cause: unknown): string | undefined {
+ const messages: string[] = [];
+ const seen = new Set();
+ while (cause !== undefined && !seen.has(cause)) {
+ seen.add(cause);
+ if (Cause.isCause(cause)) {
+ cause = Cause.squash(cause);
+ continue;
+ }
+ const message = typeof cause === "string" ? cause : stringField(cause, "message");
+ if (message) messages.push(message);
+ cause = cause instanceof Error ? cause.cause : undefined;
+ }
+ return messages.join(": ") || undefined;
+}
+
function redactUrl(match: string): string {
const trailing = /[),.;!?]+$/u.exec(match)?.[0] ?? "";
const candidate = trailing.length === 0 ? match : match.slice(0, -trailing.length);
@@ -95,10 +112,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 ?? causeMessage(input.cause) ?? 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..3ec1f8bd6b90 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.include(error?.failure.message ?? "", "provider process exited");
}),
);
@@ -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(
From 70d98fe454f358b8decace5117e04d2c3372b1bc Mon Sep 17 00:00:00 2001
From: Bil0000 <62337003+Bil0000@users.noreply.github.com>
Date: Thu, 17 Sep 2026 18:57:55 +0200
Subject: [PATCH 5/6] fix(server): keep arbitrary causes out of provider
failure messages
---
.../orchestration-v2/ProviderFailure.test.ts | 29 +++++++------------
.../src/orchestration-v2/ProviderFailure.ts | 19 +-----------
.../RunExecutionService.test.ts | 2 +-
3 files changed, 12 insertions(+), 38 deletions(-)
diff --git a/apps/server/src/orchestration-v2/ProviderFailure.test.ts b/apps/server/src/orchestration-v2/ProviderFailure.test.ts
index 0f8accf1abf6..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,25 +67,15 @@ it("does not split a surrogate pair at the truncation boundary", () => {
assert.notMatch(failure.message.slice(0, -1), /[\uD800-\uDBFF]$/u);
});
-it("keeps nested error messages without exposing their stacks or credentials", () => {
- const root = new Error("ACP rejected the event: api_key=nested-secret");
- const failure = makeProviderFailure({
- cause: new Error("Failed while ingesting orchestration V2 run execution.", {
- cause: new Error("Failed to normalize provider event turn_item.updated.", { cause: root }),
- }),
- });
-
- assert.include(failure.message, "Failed to normalize provider event turn_item.updated.");
- assert.include(failure.message, "ACP rejected the event: api_key=[REDACTED]");
- assert.notInclude(failure.message, "nested-secret");
- assert.notInclude(failure.message, "ProviderFailure.test.ts");
-});
-
-it("stops at repeated errors in a cause chain", () => {
- const cause = new Error("Repeated error");
- cause.cause = cause;
-
- assert.equal(makeProviderFailure({ cause }).message, "Repeated error");
+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", () => {
diff --git a/apps/server/src/orchestration-v2/ProviderFailure.ts b/apps/server/src/orchestration-v2/ProviderFailure.ts
index 62220b670d2f..a0e649d25ae9 100644
--- a/apps/server/src/orchestration-v2/ProviderFailure.ts
+++ b/apps/server/src/orchestration-v2/ProviderFailure.ts
@@ -10,7 +10,6 @@ import type {
RunId,
ThreadId,
} from "@t3tools/contracts";
-import * as Cause from "effect/Cause";
import type * as DateTime from "effect/DateTime";
import type { IdAllocatorV2Shape } from "./IdAllocator.ts";
@@ -30,22 +29,6 @@ function stringField(value: unknown, key: "message" | "code"): string | undefine
}
}
-function causeMessage(cause: unknown): string | undefined {
- const messages: string[] = [];
- const seen = new Set();
- while (cause !== undefined && !seen.has(cause)) {
- seen.add(cause);
- if (Cause.isCause(cause)) {
- cause = Cause.squash(cause);
- continue;
- }
- const message = typeof cause === "string" ? cause : stringField(cause, "message");
- if (message) messages.push(message);
- cause = cause instanceof Error ? cause.cause : undefined;
- }
- return messages.join(": ") || undefined;
-}
-
function redactUrl(match: string): string {
const trailing = /[),.;!?]+$/u.exec(match)?.[0] ?? "";
const candidate = trailing.length === 0 ? match : match.slice(0, -trailing.length);
@@ -112,7 +95,7 @@ export function makeProviderFailure(input: {
readonly class?: OrchestrationV2ProviderFailureClass;
readonly retryable?: boolean | null;
}): OrchestrationV2ProviderFailure {
- const rawMessage = input.message ?? causeMessage(input.cause) ?? 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 3ec1f8bd6b90..a34a5ffc1445 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
@@ -2940,7 +2940,7 @@ it.effect("refreshes pull requests after a provider stream exits with an error",
["error"],
);
const error = written.find((item) => item.type === "error");
- assert.include(error?.failure.message ?? "", "provider process exited");
+ assert.equal(error?.failure.message, "Provider turn failed.");
}),
);
From 601b2784fe633fb1f7e4cb2ab8f78b24c513f328 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 17 Sep 2026 12:11:33 -0700
Subject: [PATCH 6/6] fix(client): keep preparing turns bound before session
---
.../threads/thread-provider-switching.test.ts | 16 +++++++++++++++-
.../client-runtime/src/state/threadWorkflows.ts | 11 +++++++----
2 files changed, 22 insertions(+), 5 deletions(-)
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 63a14853b3eb..950a4dd33685 100644
--- a/apps/mobile/src/features/threads/thread-provider-switching.test.ts
+++ b/apps/mobile/src/features/threads/thread-provider-switching.test.ts
@@ -60,7 +60,7 @@ describe("threadAllowsProviderSwitch", () => {
threadAllowsProviderSwitch({
thread: startedThread,
projection: {
- thread: { id: "thread", activeProviderThreadId: null },
+ thread: { id: "thread", activeProviderThreadId: null, historyOrigin: "v1_import" },
runs: [],
providerThreads: [],
providerSessions: [],
@@ -69,6 +69,20 @@ describe("threadAllowsProviderSwitch", () => {
).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/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts
index 25aebab2f2cb..e201e3c6f409 100644
--- a/packages/client-runtime/src/state/threadWorkflows.ts
+++ b/packages/client-runtime/src/state/threadWorkflows.ts
@@ -87,11 +87,14 @@ function resolveThreadProviderSession(projection: Projection): ProviderSession |
}
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 (
- projection != null &&
- (resolveThreadProviderSession(projection)?.capabilities.sessions
- .supportsProviderSwitchingViaHandoff ??
- true)
+ resolveActiveThreadRun(projection) === null &&
+ (projection.thread.historyOrigin === "v1_import" || projection.runs.length === 0)
);
}