Skip to content
Closed
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
11 changes: 3 additions & 8 deletions apps/mobile/src/state/thread-model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { scopedThreadKey } from "../lib/scopedEntities";
import * as Dialog from "../components/ui/Dialog";
import { appAtomRegistry } from "./atom-registry";
import { readEnvironmentScope } from "./session";
import { environmentThreadShells, threadEnvironment } from "./threads";
import { threadEnvironment } from "./threads";
import {
releaseComposerDraftModelSelection,
updateComposerDraftSettings,
Expand All @@ -25,13 +25,8 @@ export function selectThreadModel(
// Its own object, so an older reply for an equal pick cannot release this one.
const modelSelection = { ...value };
updateComposerDraftSettings(threadKey, { modelSelection });
const thread = appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
// Another instance replaces the live session, which may hold an open question; that pick
// still waits for a sent message.
if (
thread?.modelSelection.instanceId !== modelSelection.instanceId ||
!readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope)
) {
// Cross-instance may stage for the next turn while the current turn keeps running.
if (!readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope)) {
return;
}
void runAtomCommand(
Expand Down
30 changes: 22 additions & 8 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,29 @@ export function useThreadComposerState(input: {
const selectedDraft = useComposerDraftSettings(selectedThreadKey);
const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
const selectedThread = selectedThreadShell;
const modelSelection = selectedDraft.modelSelection ?? selectedThread?.modelSelection ?? null;
const modelSelection =
selectedDraft.modelSelection ??
selectedThread?.nextTurnModelSelection ??
selectedThread?.modelSelection ??
null;
const serverConfig = selectedEnvironmentRuntime?.serverConfig;
const pendingHandoff = resolvePendingHandoff(
selectedThreadProjection?.projection,
isModelSelectionUnavailable(serverConfig, modelSelection, selectedThread?.projectId ?? null)
? null
: modelSelection,
serverConfig?.providers ?? [],
);
const stagedProviderSwitch =
selectedThread?.nextTurnModelSelection != null &&
selectedThread.nextTurnModelSelection.instanceId !== selectedThread.modelSelection.instanceId
? {
from: [selectedThread.modelSelection],
to: selectedThread.nextTurnModelSelection,
}
: null;
const pendingHandoff =
stagedProviderSwitch ??
resolvePendingHandoff(
selectedThreadProjection?.projection,
isModelSelectionUnavailable(serverConfig, modelSelection, selectedThread?.projectId ?? null)
? null
: modelSelection,
serverConfig?.providers ?? [],
);
const runtimeMode = selectedDraft.runtimeMode ?? selectedThread?.runtimeMode ?? null;
// Plan mode is not offered to users; every send runs in build mode.
const interactionMode = selectedThread ? DEFAULT_PROVIDER_INTERACTION_MODE : null;
Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/orchestration-v2/ContextHandoffService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,80 @@ it("covers the run range of a very long delta", () => {
);
});

it("frames a provider switch with durable thread identity and place", () => {
const threadId = ThreadId.make("thread:provider-switch");
const coverage = ContextHandoffService.makeProviderHandoffIdentityCoverage({
threadId,
coveredRunOrdinals: { from: 1, to: 3 },
items: [importedItem({ role: "user", id: "switch-user", text: "Keep going", ordinal: 1 })],
identity: ContextHandoffService.providerHandoffIdentity({
thread: {
title: "Ship the handoff",
worktreePath: "/repo/worktrees/switch",
branch: "agent/provider-switch",
},
fromProviderInstanceId: ProviderInstanceId.make("codex"),
toProviderInstanceId: ProviderInstanceId.make("claude"),
runs: [{ status: "queued" }, { status: "completed" }, { status: "queued" }],
reason: "Codex usage window",
}),
});

assert.include(coverage, "Provider switch.");
assert.include(coverage, `Thread: ${threadId}.`);
assert.include(coverage, "Title: Ship the handoff.");
assert.include(coverage, "Workspace: /repo/worktrees/switch (branch agent/provider-switch).");
assert.include(coverage, "Harness: codex → claude.");
assert.include(coverage, "Open queued runs on this thread: 2.");
assert.include(coverage, "Switch reason: Codex usage window.");
assert.include(coverage, "Native conversation starts fresh");
assert.include(coverage, "Provider context handoff.");
});

it.layer(layerTest)("ContextHandoffService provider identity handoff", (it) => {
it.effect("embeds thread identity framing in the provider handoff summary", () =>
Effect.gen(function* () {
const service = yield* ContextHandoffService.ContextHandoffServiceV2;
const handoff = yield* service.prepareProviderHandoff({
threadId: ThreadId.make("thread:identity-handoff"),
targetRunId: RunId.make("run:identity-handoff"),
transferId: ContextTransferId.make("transfer:identity-handoff"),
fromProviderThreadIds: [ProviderThreadId.make("provider-thread:codex")],
toProviderThreadId: ProviderThreadId.make("provider-thread:claude"),
fromProviderInstanceId: ProviderInstanceId.make("codex"),
toProviderInstanceId: ProviderInstanceId.make("claude"),
coveredRunOrdinals: { from: 1, to: 1 },
strategy: "full_thread_summary",
items: [
importedItem({
role: "user",
id: "identity-handoff-user",
text: "Switch harnesses without losing the thread",
ordinal: 1,
}),
],
identity: ContextHandoffService.providerHandoffIdentity({
thread: {
title: "Identity handoff",
worktreePath: "/repo",
branch: "main",
},
fromProviderInstanceId: ProviderInstanceId.make("codex"),
toProviderInstanceId: ProviderInstanceId.make("claude"),
}),
createdAt: DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"),
});

assert.equal(handoff.strategy, "full_thread_summary");
assert.include(handoff.summaryText, "Provider switch.");
assert.include(handoff.summaryText, "Title: Identity handoff.");
assert.include(handoff.summaryText, "Workspace: /repo (branch main).");
assert.include(handoff.summaryText, "Switch harnesses without losing the thread");
assert.include(handoff.history?.coverage ?? "", "Harness: codex → claude.");
}),
);
});

it.effect("inherited history stops at a missing fork ancestor", () =>
Effect.gen(function* () {
const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");
Expand Down
84 changes: 83 additions & 1 deletion apps/server/src/orchestration-v2/ContextHandoffService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface ContextHandoffServiceV2Shape {
"delta_since_target_last_seen" | "full_thread_summary"
>;
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
readonly identity?: ProviderHandoffIdentity;
readonly createdAt: DateTime.Utc;
}) => Effect.Effect<OrchestrationV2ContextHandoff, ContextHandoffServiceV2Error>;
/** Parent activity after the fork point, prepended to a side chat's first provider turn. */
Expand Down Expand Up @@ -114,6 +115,78 @@ function compactText(text: string, maxLength = 240): string {
return `${compacted.slice(0, last >= 0xd800 && last <= 0xdbff ? end - 1 : end)}...`;
}

/** Durable thread identity carried across Claude↔Codex (and other) harness switches. */
export type ProviderHandoffIdentity = {
readonly title: string | null;
readonly worktreePath: string | null;
readonly branch: string | null;
readonly fromProviderInstanceId: ProviderInstanceId;
readonly toProviderInstanceId: ProviderInstanceId;
readonly openQueuedRunCount?: number;
readonly reason?: string | null;
};

export function providerHandoffIdentity(input: {
readonly thread: Pick<OrchestrationV2AppThread, "title" | "worktreePath" | "branch">;
readonly fromProviderInstanceId: ProviderInstanceId;
readonly toProviderInstanceId: ProviderInstanceId;
readonly runs?: ReadonlyArray<Pick<OrchestrationV2Run, "status">>;
readonly reason?: string | null;
}): ProviderHandoffIdentity {
return {
title: input.thread.title,
worktreePath: input.thread.worktreePath,
branch: input.thread.branch,
fromProviderInstanceId: input.fromProviderInstanceId,
toProviderInstanceId: input.toProviderInstanceId,
openQueuedRunCount: input.runs?.filter((run) => run.status === "queued").length ?? 0,
...(input.reason === undefined ? {} : { reason: input.reason }),
};
}

/**
* Cross-harness switch framing (OpenRig set-runtime-inspired): same thread name/place/open
* work; fresh native conversation; only the handover carries prior context.
*/
export function makeProviderHandoffIdentityCoverage(input: {
readonly threadId: ThreadId;
readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"];
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
readonly identity: ProviderHandoffIdentity;
}): string {
const place =
input.identity.worktreePath === null
? "unresolved workspace"
: input.identity.branch === null || input.identity.branch === ""
? input.identity.worktreePath
: `${input.identity.worktreePath} (branch ${input.identity.branch})`;
const queued =
input.identity.openQueuedRunCount !== undefined && input.identity.openQueuedRunCount > 0
? `Open queued runs on this thread: ${input.identity.openQueuedRunCount}.`
: "No open queued runs on this thread.";
const reason =
input.identity.reason !== undefined &&
input.identity.reason !== null &&
input.identity.reason !== ""
? `Switch reason: ${compactText(input.identity.reason)}.`
: null;
return [
"Provider switch. You continue the same thread; keep its name, workspace, and open work.",
`Thread: ${input.threadId}.`,
`Title: ${input.identity.title ?? "(untitled)"}.`,
`Workspace: ${place}.`,
`Harness: ${input.identity.fromProviderInstanceId} → ${input.identity.toProviderInstanceId}.`,
queued,
...(reason === null ? [] : [reason]),
"Native conversation starts fresh; only this handover carries prior context. Earlier activity is summarized below.",
handoffCoverage({
threadId: input.threadId,
coveredRunOrdinals: input.coveredRunOrdinals,
items: input.items,
}),
].join("\n");
}

export function visibleDeltaRunOrdinals(
projection: { readonly runs: ReadonlyArray<Pick<OrchestrationV2Run, "id" | "ordinal">> },
items: ReadonlyArray<Pick<OrchestrationV2TurnItem, "runId">>,
Expand Down Expand Up @@ -500,6 +573,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi
"delta_since_target_last_seen" | "full_thread_summary"
>;
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
readonly identity?: ProviderHandoffIdentity;
readonly createdAt: DateTime.Utc;
}) {
const handoffId = yield* idAllocator.allocate
Expand All @@ -521,7 +595,15 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi
),
);
const runStatuses = new Map(input.runs?.map((run) => [run.id, run.status]));
const coverage = handoffCoverage(input);
const coverage =
input.identity === undefined
? handoffCoverage(input)
: makeProviderHandoffIdentityCoverage({
threadId: input.threadId,
coveredRunOrdinals: input.coveredRunOrdinals,
items: input.items,
identity: input.identity,
});
const selected = selectHistory({
messages: input.items.flatMap((item) => {
const message = historicalMessage(item);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ it.effect.each(
})
.pipe(Effect.result);
const after = yield* projections.getThreadProjection(threadId);
if (active && selectionChange !== "next-turn") {
if (active && selectionChange === "restart") {
assert.deepEqual(
after,
before,
Expand All @@ -216,6 +216,26 @@ it.effect.each(
assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence);
assert.ok(Result.isFailure(outcome));
assert.instanceOf(outcome.failure, Orchestrator.OrchestratorProviderSelectionBusyError);
} else if (active && selectionChange === "instance") {
// Cross-harness / create_with_handoff stages next-turn while the turn keeps running.
assert.ok(Result.isSuccess(outcome));
assert.deepEqual(after.thread.modelSelection, before.thread.modelSelection);
assert.equal(after.thread.providerInstanceId, before.thread.providerInstanceId);
assert.deepEqual(after.thread.nextTurnModelSelection, target);
assert.deepEqual(after.runs, before.runs);
assert.deepEqual(after.providerTurns, before.providerTurns);
assert.deepEqual(after.providerSessions, before.providerSessions);
assert.deepEqual(
yield* outbox.listByCommandId(commandId),
[],
"a staged next-turn switch must not detach the live provider session mid-turn",
);
assert.isAbove(yield* orchestrator.getThreadEventSequence(threadId), sequence);
assert.isTrue(
after.turnItems.some(
(item) => item.type === "system_notice" && item.title === "Provider switch staged",
),
);
} else {
assert.ok(Result.isSuccess(outcome));
assert.deepEqual(after.thread.modelSelection, target);
Expand Down
Loading
Loading