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
22 changes: 22 additions & 0 deletions apps/server/src/orchestration-v2/ProviderSwitchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,28 @@ it.effect("falls back to the native provider thread when every recorded session
),
);

it.effect("hands off from a native provider thread after its session detaches", () =>
Effect.gen(function* () {
const service = yield* ProviderSwitch.ProviderSwitchServiceV2;
const result = yield* service.plan({
projection: { ...deadNativeThreadProjection("stopped"), providerSessions: [] },
targetModelSelection: {
instanceId: ProviderInstanceId.make("codex_other"),
model: "gpt-5.2-codex",
},
});
assert.equal(result.transition.type, "create_with_handoff");
assert.deepEqual(result.releaseProviderSessionIds, []);
}).pipe(
Effect.provide(
testLayer({
[currentInstanceId]: { continuationKey: "codex:account:primary" },
codex_other: { continuationKey: "codex:account:other" },
}),
),
),
);

for (const deadStatus of ["stopped", "error"] as const) {
it.effect(
`applies a model change on next turn when a ${deadStatus} session negotiated model switching`,
Expand Down
37 changes: 37 additions & 0 deletions packages/client-runtime/src/state/threadWorkflows.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vite-plus/test";
import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";

import {
canDetachThreadProviderSession,
canForkProjectedAssistantItem,
deriveThreadQueueWorkflowState,
resolveLatestMergeBackRun,
threadSupportsProviderHandoff,
} from "./threadWorkflows.ts";

const capabilities = (input?: {
Expand All @@ -29,6 +31,41 @@ const capabilities = (input?: {
}) as never;

describe("thread workflows", () => {
it("allows a completed thread to switch providers after its session detaches", () => {
const projection = {
thread: {
id: "thread",
activeProviderThreadId: "provider-thread",
modelSelection: { instanceId: "cursor", model: "grok-4.7" },
},
runs: [{ id: "run", status: "completed" }],
providerSessions: [],
providerThreads: [
{
id: "provider-thread",
appThreadId: "thread",
providerInstanceId: "cursor",
providerSessionId: "detached-session",
nativeThreadRef: { driver: "cursor", nativeId: "agent-123", strength: "strong" },
},
],
} as unknown as OrchestrationV2ThreadProjection;

expect(threadSupportsProviderHandoff(projection)).toBe(true);
expect(
threadSupportsProviderHandoff({
...projection,
runs: [{ ...projection.runs[0]!, status: "running" }],
}),
).toBe(false);
expect(
threadSupportsProviderHandoff({
...projection,
providerThreads: [{ ...projection.providerThreads[0]!, nativeThreadRef: null }],
}),
).toBe(false);
});

it("sorts queued messages and gates reorder and promotion from capabilities", () => {
const state = deriveThreadQueueWorkflowState({
thread: { id: "thread", activeProviderThreadId: "provider-thread" },
Expand Down
14 changes: 11 additions & 3 deletions packages/client-runtime/src/state/threadWorkflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,17 @@ export function threadSupportsProviderHandoff(projection: Projection | null | un
if (session !== null) {
return session.capabilities.sessions.supportsProviderSwitchingViaHandoff;
}
return (
resolveActiveThreadRun(projection) === null &&
(projection.thread.historyOrigin === "v1_import" || projection.runs.length === 0)
if (resolveActiveThreadRun(projection) !== null) return false;
if (projection.thread.historyOrigin === "v1_import" || projection.runs.length === 0) return true;

// Detaching a stopped session removes it from the projection, but its native
// provider thread remains available for the next turn's portable handoff.
return projection.providerThreads.some(
(thread) =>
thread.id === projection.thread.activeProviderThreadId &&
thread.appThreadId === projection.thread.id &&
thread.providerInstanceId === projection.thread.modelSelection.instanceId &&
thread.nativeThreadRef !== null,
);
}

Expand Down
Loading