Skip to content

Commit d02490e

Browse files
committed
Drop a submitted prompt after session rotation
A clear after Enter could finish token refresh against the rebuilt agent, so the outgoing prompt started the new session.
1 parent 60a0501 commit d02490e

3 files changed

Lines changed: 161 additions & 3 deletions

File tree

src/tui/runner/exit.test.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { describe, expect, spyOn, test } from "bun:test";
2+
import { EventEmitter } from "node:events";
3+
import { type Agent } from "@intx/agent";
24
import { getLogger } from "@intx/log";
5+
import type { InferenceSource } from "@intx/types/runtime";
36

7+
import * as codexSession from "../../auth/codex/session.js";
48
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
59
import { defined } from "../../../tests/helpers/defined.js";
6-
import { finalizeTUIRun } from "./exit.js";
10+
import { createDeliveryGeneration } from "../queued-delivery.js";
11+
import { createSessionOperationQueue } from "../session-operation-queue.js";
12+
import {
13+
createRunLifecycle,
14+
finalizeTUIRun,
15+
resetSessionForRotation,
16+
} from "./exit.js";
717
import type { RunnerServices, RunnerState } from "./state.js";
818

919
function stubQuit(args: {
@@ -114,3 +124,136 @@ describe("finalizeTUIRun quit order", () => {
114124
await expect(pending).rejects.toThrow("stop");
115125
});
116126
});
127+
128+
const liveSource: InferenceSource = {
129+
id: "codex/work",
130+
provider: "openai",
131+
baseURL: "https://example.test",
132+
apiKey: "old-token",
133+
model: "m",
134+
};
135+
136+
function recordingAgent(sends: string[]): Agent {
137+
return {
138+
send: async (content) => {
139+
sends.push(typeof content === "string" ? content : String(content));
140+
return { type: "reply", reply: "", turn: {} as never };
141+
},
142+
stream: async function* stream() {
143+
yield* [];
144+
},
145+
deliver: () => undefined,
146+
close: async () => undefined,
147+
setSource: () => undefined,
148+
setSources: () => undefined,
149+
history: async () => [],
150+
checkpoints: async () => [],
151+
readAt: async () => [],
152+
get blobReader() {
153+
return {} as Agent["blobReader"];
154+
},
155+
};
156+
}
157+
158+
function stubSendLifecycle(agent: Agent): {
159+
state: RunnerState;
160+
services: RunnerServices;
161+
} {
162+
const state = {
163+
runTaskTitle: "keep-title",
164+
liveSource,
165+
connectedMcpServers: [],
166+
config: { cwd: "/tmp", task: "keep-title" },
167+
sessionId: "s",
168+
startedAt: 1,
169+
inFlight: 0,
170+
fatalBuildError: null,
171+
sendAborted: false,
172+
initialCodexProfile: "work",
173+
initialXaiProfile: undefined,
174+
stampProvider: { fn: undefined },
175+
} as unknown as RunnerState;
176+
const services = {
177+
crashGuard: {
178+
isFinalized: () => true,
179+
setPartialFlush: () => undefined,
180+
},
181+
cycleRecorder: {
182+
dispose: async () => "",
183+
handleEvent: () => undefined,
184+
},
185+
providerFailureAttempts: {},
186+
correlationAcceptance: { observe: () => undefined },
187+
runSink: { sink: () => undefined },
188+
sessionCost: { addTurn: () => undefined },
189+
buildAgent: async () => agent,
190+
emitter: new EventEmitter(),
191+
sessionOps: createSessionOperationQueue(),
192+
deliveryGeneration: createDeliveryGeneration(),
193+
toolset: { setToolPromoter: () => undefined },
194+
subAgentSessions: { cancelAll: async () => [] },
195+
activeRunHandle: { task: "", startedAt: 0, model: "" },
196+
} as unknown as RunnerServices;
197+
return { state, services };
198+
}
199+
200+
function hangCodexRefresh(): {
201+
settle: (value: { access: string }) => void;
202+
spy: ReturnType<typeof spyOn>;
203+
} {
204+
let settle: ((value: { access: string }) => void) | undefined;
205+
const spy = spyOn(codexSession, "getValidCodexToken").mockImplementation(
206+
() =>
207+
new Promise<{ access: string }>((resolve) => {
208+
settle = resolve;
209+
}),
210+
);
211+
return {
212+
spy,
213+
settle: (value) => defined(settle, "settleRefresh")(value),
214+
};
215+
}
216+
217+
describe("agentProxy.send vs /clear", () => {
218+
test("a /clear during hung OAuth after awaitTail does not send into the rebuilt agent", async () => {
219+
const oldSends: string[] = [];
220+
const newSends: string[] = [];
221+
const oldAgent = recordingAgent(oldSends);
222+
const newAgent = recordingAgent(newSends);
223+
const { state, services } = stubSendLifecycle(oldAgent);
224+
const hung = hangCodexRefresh();
225+
try {
226+
const { agentProxy } = await createRunLifecycle(state, services);
227+
const pending = agentProxy.send("keep me out of the new session");
228+
await Promise.resolve();
229+
await Promise.resolve();
230+
231+
resetSessionForRotation(state, services);
232+
state.currentAgent = newAgent;
233+
hung.settle({ access: "fresh-token" });
234+
await Promise.allSettled([pending]);
235+
236+
expect(newSends).toEqual([]);
237+
expect(oldSends).toEqual([]);
238+
} finally {
239+
hung.spy.mockRestore();
240+
}
241+
});
242+
243+
test("hung OAuth after awaitTail still sends when the session is not rotated", async () => {
244+
const sends: string[] = [];
245+
const { state, services } = stubSendLifecycle(recordingAgent(sends));
246+
const hung = hangCodexRefresh();
247+
try {
248+
const { agentProxy } = await createRunLifecycle(state, services);
249+
const pending = agentProxy.send("deliver this");
250+
await Promise.resolve();
251+
await Promise.resolve();
252+
hung.settle({ access: "fresh-token" });
253+
await pending;
254+
expect(sends).toEqual(["deliver this"]);
255+
} finally {
256+
hung.spy.mockRestore();
257+
}
258+
});
259+
});

src/tui/runner/exit.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
* proxy), the stream sink, and the quit-time finalization tail.
66
*/
77

8-
import { AgentContextLockError, type Agent } from "@intx/agent";
8+
import {
9+
AgentClosedError,
10+
AgentContextLockError,
11+
type Agent,
12+
} from "@intx/agent";
913
import { getLogger } from "@intx/log";
1014
import type { InferenceSource } from "@intx/types/runtime";
1115
import { consumeStream } from "../../session/stream-consumer.js";
@@ -300,7 +304,8 @@ export async function createRunLifecycle(
300304

301305
// Serial operation queue. Rotation (reload, interrupt, newSession), compaction
302306
// continuation, and proxy deliver enqueue async tasks; they run one at a time.
303-
// `send` awaits the tail before dispatching so it never races a concurrent rebuild.
307+
// `send` awaits the tail, then drops if /clear|/new bumped delivery generation
308+
// during the wait or token refresh so the prompt cannot land on the rebuilt agent.
304309
const enqueueOp = services.sessionOps.enqueue;
305310

306311
const reloadIfIdle = (): void => {
@@ -407,7 +412,14 @@ export async function createRunLifecycle(
407412
// Host mounts later; stampProvider.fn is wired once the bridge exists.
408413
const agentProxy: Agent = {
409414
send: async (content, opts) => {
415+
const stillCurrent = services.deliveryGeneration.capture();
416+
const dropIfRotated = (): void => {
417+
if (stillCurrent()) return;
418+
state.sendAborted = true;
419+
throw new AgentClosedError();
420+
};
410421
await services.sessionOps.awaitTail();
422+
dropIfRotated();
411423
if (state.fatalBuildError !== null) throw state.fatalBuildError;
412424
const trimmed = typeof content === "string" ? content.trim() : "";
413425
if (trimmed.length > 0 && state.runTaskTitle.trim().length === 0) {
@@ -422,6 +434,7 @@ export async function createRunLifecycle(
422434
return await runWhileAgentBusy(state, async () => {
423435
await refreshCodexBeforeSend();
424436
await refreshXaiBeforeSend();
437+
dropIfRotated();
425438
return await liveAgent(state).send(content, opts);
426439
});
427440
},

src/tui/runner/submit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export function createSubmitPath(
313313
text: string,
314314
pending: readonly PendingImageAttachment[],
315315
): Promise<void> => {
316+
const stillCurrent = services.deliveryGeneration.capture();
316317
state.sendAborted = false;
317318
if (text.trim().length > 0) {
318319
void appendSentMessage(state.config.cwd, state.sessionId, text).catch(
@@ -329,6 +330,7 @@ export function createSubmitPath(
329330
imageAttachmentFromPath,
330331
pending,
331332
);
333+
if (!stillCurrent()) return;
332334
await sendWithAttemptIdentity(
333335
userInboundMessage(ingested.text, ingested.attachments),
334336
);

0 commit comments

Comments
 (0)