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} + )}