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
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe("conversation execution store", () => {
readThroughPlaneSeq: 12,
established: true,
agentDefinitionId: "agent-a",
cliAgentType: "codex",
},
"2026-08-25T00:00:00Z",
backing
Expand All @@ -111,6 +112,7 @@ describe("conversation execution store", () => {
expect(loadStoredContinuation("scope", "root", backing)).toMatchObject({
continuationSessionId: "runner-1",
readThroughPlaneSeq: 12,
cliAgentType: "codex",
});
expect(
loadStoredOwnerPlaneCursor("scope", "root", backing)?.readThroughPlaneSeq
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface ConversationContinuationRecord {
established: boolean;
bootstrapTurnIntentId?: string;
agentDefinitionId: string;
/** Managed External CLI platform; absent means the native Agent runtime. */
cliAgentType?: string;
accountId?: string;
model?: string;
workspaceRepoPath?: string | null;
Expand Down Expand Up @@ -143,6 +145,8 @@ function sanitizeContinuation(
if (accountId) record.accountId = accountId;
const model = optionalString(value, "model");
if (model) record.model = model;
const cliAgentType = optionalString(value, "cliAgentType");
if (cliAgentType) record.cliAgentType = cliAgentType;
if (
typeof value.workspaceRepoPath === "string" ||
value.workspaceRepoPath === null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEvent } from "@src/engines/SessionCore/core/types";
import { SessionService } from "@src/engines/SessionCore/services/SessionService";
import { sendReservedTurn } from "@src/engines/SessionCore/services/TurnDispatchService";
import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession";

import type { CloudConversationEvent } from "../org2CloudConversationEventsClient";
import { loadContinuation, saveContinuation } from "./conversationContinuation";
Expand Down Expand Up @@ -368,6 +369,53 @@ describe("conversation turn continuation", () => {
});
});

it("rejects a durable redelivery that cannot recover its exact prepared runner", async () => {
saveContinuation("scope", "root", {
continuationSessionId: "runner-other",
readThroughPlaneSeq: 10,
established: true,
agentDefinitionId: "agent-a",
});

await expect(
runConversationTurn(
params({ requiredRunnerSessionId: "runner-prepared" })
)
).rejects.toThrow(
"requires prepared runner runner-prepared; local continuation is runner-other"
);

expect(SessionService.create).not.toHaveBeenCalled();
expect(sendReservedTurn).not.toHaveBeenCalled();
expect(state.pushes).toEqual([]);
});

it("never rolls an exact prepared runner when its execution fingerprint differs", async () => {
saveContinuation("scope", "root", {
continuationSessionId: "runner-prepared",
readThroughPlaneSeq: 10,
established: true,
agentDefinitionId: "agent-old",
});

await expect(
runConversationTurn(
params({
requiredRunnerSessionId: "runner-prepared",
assignedAgentDefinitionId: "agent-new",
})
)
).rejects.toThrow(
"prepared runner runner-prepared is incompatible: assigned_agent_changed"
);

expect(SessionService.create).not.toHaveBeenCalled();
expect(state.cleaned).toEqual([]);
expect(loadContinuation("scope", "root")?.continuationSessionId).toBe(
"runner-prepared"
);
});

it("keeps a fresh prepared runner recoverable when its first send is ambiguous", async () => {
state.rejectNextSend = true;
const onTurnAccepted = vi.fn();
Expand Down Expand Up @@ -415,6 +463,44 @@ describe("conversation turn continuation", () => {
});
});

it("creates a managed External CLI through the same continuation path", async () => {
vi.mocked(requestForkSessionSetup).mockResolvedValueOnce({
workspaceRepoPath: "/repo",
execution: {
agentDefinitionId: "agent-a",
cliAgentType: "codex",
},
});
state.persistedBatches = [
[
event("user-1", "user", "new request", "intent-1"),
event("agent-1", "assistant", "answer"),
],
];

await runConversationTurn(params({ assignedAgentDefinitionId: "agent-a" }));

expect(requestForkSessionSetup).toHaveBeenCalledWith(
expect.objectContaining({
allowCliRuntime: true,
lockSourceAgent: true,
sourceAgentDefinitionId: "agent-a",
})
);
expect(SessionService.create).toHaveBeenCalledWith(
expect.objectContaining({
task: "",
cliAgentType: "codex",
agentDefinitionId: "agent-a",
})
);
expect(loadContinuation("scope", "root")).toMatchObject({
continuationSessionId: "fresh-runner",
cliAgentType: "codex",
agentDefinitionId: "agent-a",
});
});

it("clears and cleans a failed execution episode", async () => {
state.terminalStatus = "failed";
state.persistedBatches = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ export interface RunConversationTurnParams {
executionScopeKey: string;
/** Stable logical id for durable redelivery; minted for ordinary chat. */
turnIntentId?: string;
/**
* Durable redelivery fence. Once the backend has prepared a local runner,
* the same run may only resume that exact Session after a restart.
*/
requiredRunnerSessionId?: string;
/**
* A durable caller may bind this runner before transport send. In that
* mode a send error must escape to the caller instead of silently rolling
Expand Down Expand Up @@ -417,11 +422,27 @@ async function runConversationTurnSerialized(
params.executionScopeKey,
params.rootSessionId
);
if (
params.requiredRunnerSessionId &&
record?.continuationSessionId !== params.requiredRunnerSessionId
) {
throw new Error(
`conversation turn ${turnIntentId} requires prepared runner ` +
`${params.requiredRunnerSessionId}; local continuation is ` +
`${record?.continuationSessionId ?? "missing"}`
);
}
const decision = decideContinuation({
record,
turnIntentId,
assignedAgentDefinitionId: params.assignedAgentDefinitionId,
});
if (params.requiredRunnerSessionId && decision.kind === "fresh") {
throw new Error(
`prepared runner ${params.requiredRunnerSessionId} is incompatible: ` +
`${decision.rollReason ?? "continuation unavailable"}`
);
}
if (decision.kind === "fresh" && decision.rollReason) {
log.info(`rolling conversation continuation: ${decision.rollReason}`);
clearContinuation(params.executionScopeKey, params.rootSessionId);
Expand Down Expand Up @@ -544,6 +565,8 @@ async function startFreshEpisode(
sourceScopeKey: params.sourceScopeKey,
sourceModel: params.sourceModel,
sourceAgentDefinitionId: params.assignedAgentDefinitionId,
allowCliRuntime: true,
lockSourceAgent: Boolean(params.assignedAgentDefinitionId),
});
const remembered = loadForkSetupMemory(setupMemoryKey);
let usedRememberedSetup = Boolean(remembered);
Expand All @@ -560,6 +583,7 @@ async function startFreshEpisode(
repoPath: setup.workspaceRepoPath ?? undefined,
model: setup.execution.model,
accountId: setup.execution.accountId,
cliAgentType: setup.execution.cliAgentType,
keySource: "own_key",
agentDefinitionId: setup.execution.agentDefinitionId,
mode: "build",
Expand All @@ -580,7 +604,10 @@ async function startFreshEpisode(
if (usedRememberedSetup) {
Message.info(
i18n.t("navigation:collaboration.session.forkSetupReused", {
model: setup.execution.model ?? setup.execution.agentDefinitionId,
model:
setup.execution.model ??
setup.execution.cliAgentType ??
setup.execution.agentDefinitionId,
})
);
}
Expand All @@ -595,6 +622,7 @@ async function startFreshEpisode(
established: false,
bootstrapTurnIntentId: io.turnIntentId,
agentDefinitionId: setup.execution.agentDefinitionId,
cliAgentType: setup.execution.cliAgentType,
accountId: setup.execution.accountId,
model: setup.execution.model,
workspaceRepoPath: setup.workspaceRepoPath ?? null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ describe("handleWorkItemConversationTurnRequest", () => {
orgId: "cloud-org",
rootSessionId: "root-remote",
turnIntentId: "run-1",
requiredRunnerSessionId: "runner-prepared",
displayText: "💬 please retry",
agentContent: REQUEST.content,
conversationTitle: "Root session",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export async function handleWorkItemConversationTurnRequest(
),
executionScopeKey,
turnIntentId: request.runId,
requiredRunnerSessionId: request.preparedRunnerSessionId ?? undefined,
preserveRunnerOnTransportFailure: true,
onRunnerReady: async (createdRunnerId, turnId, turnIntentId) => {
runnerSessionId = createdRunnerId;
Expand Down
Loading
Loading