diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 44208229f5..b7e7ed7e26 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -49,6 +49,7 @@ import { computeCredentialEpoch, configFingerprint, credentialEpochMismatch, + carriesMinimalHistory, mountCredentialsExpired, expectedNextHistoryFingerprint, historyFingerprint, @@ -595,9 +596,15 @@ export async function runWithKeepalive( existing.credentialEpoch, incomingEpoch, ); + // A last-message-only client sends no prior conversation, so there is nothing to compare: + // `priorConversation` is empty and the fingerprint can never match what the last turn stored. + // Comparing anyway evicts the warm session on every turn of every conversation. The session + // id already binds the request to this conversation; the client simply no longer asserts it. + const clientAssertsHistory = !carriesMinimalHistory(request); let mismatch: string | undefined; if (cfgFp !== existing.configFingerprint) mismatch = "config"; - else if (priorFp !== existing.historyFingerprint) mismatch = "history"; + else if (clientAssertsHistory && priorFp !== existing.historyFingerprint) + mismatch = "history"; else if (credMismatch) mismatch = credMismatch; else if (!tailIsFreshUserMessage(request)) mismatch = "tail"; diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index 04129c0bc4..c77f1ae55a 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -476,6 +476,16 @@ describe("runWithKeepalive: validation mismatches degrade to cold", () => { assert.equal(calls.acquire, 2); }); + it("a last-message-only turn 2 keeps the warm session (no history to compare)", async () => { + // The flag-on frontend sends ONLY the trailing user message, so `priorConversation` is + // empty and its fingerprint can never match what turn 1 stored. Comparing anyway evicted + // every conversation to cold on every turn. + const minimal = turn2("s1", { messages: [{ role: "user", content: "more" }] }); + const { calls, env1 } = await parkThen(minimal); + assert.equal(env1.destroyed, 0, "the warm env must survive a minimal-history turn"); + assert.equal(calls.acquire, 1, "no cold re-acquire"); + }); + it("credential-epoch expiry evicts to cold", async () => { // The parked mount expiry is in the past, so the next turn's epoch check fails. const { calls, env1 } = await parkThen(turn2(), {