Skip to content

Commit 9d9dacc

Browse files
committed
Drop leftover drain send after a generation bump
Leftover hops went through sendUserPrompt with no deliveryGeneration check, so a /clear mid-ingest still landed in the new session. Capture generation at leftover hop time and skip send and recall after ingest when the generation has bumped. Operator Enter stays ungated.
1 parent c821bc3 commit 9d9dacc

3 files changed

Lines changed: 169 additions & 8 deletions

File tree

src/tui/queued-delivery.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import type { PendingImageAttachment } from "./image-attachments.js";
33
import {
44
createDeliveryGeneration,
5+
createLeftoverSend,
56
createLiveSteerDeliver,
67
routeQueuedDelivery,
78
} from "./queued-delivery.js";
@@ -177,3 +178,101 @@ describe("createLiveSteerDeliver", () => {
177178
expect(delivered).toEqual([]);
178179
});
179180
});
181+
182+
describe("createLeftoverSend", () => {
183+
test("generation bump during ingest drops leftover send and sent-message record", async () => {
184+
const sent: string[] = [];
185+
const recorded: string[] = [];
186+
const { enqueue, awaitTail } = createSessionOperationQueue();
187+
const generation = createDeliveryGeneration();
188+
let resolveSlow!: () => void;
189+
const slow = new Promise<void>((resolve) => {
190+
resolveSlow = resolve;
191+
});
192+
const leftoverSend = createLeftoverSend({
193+
enqueue,
194+
ingest: async (text) => {
195+
if (text === "leftover") await slow;
196+
return { text, attachments: [] };
197+
},
198+
send: (text) => {
199+
sent.push(text);
200+
},
201+
recordSent: (text) => {
202+
recorded.push(text);
203+
},
204+
captureGeneration: generation.capture,
205+
onFailure: (err) => {
206+
throw err;
207+
},
208+
});
209+
210+
leftoverSend("leftover");
211+
generation.bump();
212+
resolveSlow();
213+
await awaitTail();
214+
expect(sent).toEqual([]);
215+
expect(recorded).toEqual([]);
216+
});
217+
218+
test("leftover send without a bump still sends and records", async () => {
219+
const sent: string[] = [];
220+
const recorded: string[] = [];
221+
const { enqueue, awaitTail } = createSessionOperationQueue();
222+
const leftoverSend = createLeftoverSend({
223+
enqueue,
224+
ingest: async (text) => ({ text, attachments: [] }),
225+
send: (text) => {
226+
sent.push(text);
227+
},
228+
recordSent: (text) => {
229+
recorded.push(text);
230+
},
231+
captureGeneration: () => () => true,
232+
onFailure: (err) => {
233+
throw err;
234+
},
235+
});
236+
237+
leftoverSend("follow-up");
238+
await awaitTail();
239+
expect(sent).toEqual(["follow-up"]);
240+
expect(recorded).toEqual(["follow-up"]);
241+
});
242+
243+
test("generation bump drops leftover send but not a sibling Enter send", async () => {
244+
const leftoverSent: string[] = [];
245+
const enterSent: string[] = [];
246+
const { enqueue, awaitTail } = createSessionOperationQueue();
247+
const generation = createDeliveryGeneration();
248+
let resolveSlow!: () => void;
249+
const slow = new Promise<void>((resolve) => {
250+
resolveSlow = resolve;
251+
});
252+
const leftoverSend = createLeftoverSend({
253+
enqueue,
254+
ingest: async (text) => {
255+
await slow;
256+
return { text, attachments: [] };
257+
},
258+
send: (text) => {
259+
leftoverSent.push(text);
260+
},
261+
captureGeneration: generation.capture,
262+
onFailure: (err) => {
263+
throw err;
264+
},
265+
});
266+
const enterSend = (text: string) => {
267+
enterSent.push(text);
268+
};
269+
270+
leftoverSend("queued");
271+
generation.bump();
272+
enterSend("hello");
273+
resolveSlow();
274+
await awaitTail();
275+
expect(leftoverSent).toEqual([]);
276+
expect(enterSent).toEqual(["hello"]);
277+
});
278+
});

src/tui/queued-delivery.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,34 @@ export interface CreateLiveSteerDeliverArgs {
6464
onFailure: (err: unknown) => void;
6565
}
6666

67-
/**
68-
* Live inject: enqueue ingest, then deliver, in drain order. Previously
69-
* each item started ingest immediately, so Agent.deliver could reverse.
70-
*/
71-
export function createLiveSteerDeliver(
72-
args: CreateLiveSteerDeliverArgs,
67+
export interface CreateLeftoverSendArgs {
68+
enqueue: (op: () => Promise<void>) => Promise<void>;
69+
ingest: (text: string, attachments: readonly PendingImageAttachment[]) => Promise<IngestedSteer>;
70+
/**
71+
* Post-ingest hop (agentProxy.send). Must not ingest again — leftover
72+
* ingest already ran in this wrapper.
73+
*/
74+
send: (text: string, attachments: readonly PendingImageAttachment[]) => void;
75+
/**
76+
* Up/Down recall. Called with the original text only when the hop is
77+
* still current after ingest, so a /clear|/new drop is not recorded.
78+
*/
79+
recordSent?: (text: string) => void;
80+
captureGeneration: () => () => boolean;
81+
onFailure: (err: unknown) => void;
82+
}
83+
84+
interface GenerationGatedHopArgs {
85+
enqueue: (op: () => Promise<void>) => Promise<void>;
86+
ingest: (text: string, attachments: readonly PendingImageAttachment[]) => Promise<IngestedSteer>;
87+
hop: (text: string, attachments: readonly PendingImageAttachment[]) => void;
88+
recordSent?: (text: string) => void;
89+
captureGeneration: () => () => boolean;
90+
onFailure: (err: unknown) => void;
91+
}
92+
93+
function createGenerationGatedHop(
94+
args: GenerationGatedHopArgs,
7395
): (text: string, attachments?: readonly PendingImageAttachment[]) => void {
7496
return (text, attachments) => {
7597
const stillCurrent = args.captureGeneration();
@@ -79,8 +101,29 @@ export function createLiveSteerDeliver(
79101
if (!stillCurrent()) return;
80102
const ingested = await args.ingest(text, pending);
81103
if (!stillCurrent()) return;
82-
args.deliver(ingested.text, ingested.attachments);
104+
args.recordSent?.(text);
105+
args.hop(ingested.text, ingested.attachments);
83106
})
84107
.catch(args.onFailure);
85108
};
86109
}
110+
111+
/**
112+
* Live inject: enqueue ingest, then deliver, in drain order. Previously
113+
* each item started ingest immediately, so Agent.deliver could reverse.
114+
*/
115+
export function createLiveSteerDeliver(
116+
args: CreateLiveSteerDeliverArgs,
117+
): (text: string, attachments?: readonly PendingImageAttachment[]) => void {
118+
return createGenerationGatedHop({ ...args, hop: args.deliver });
119+
}
120+
121+
/**
122+
* Leftover / queue drain hop: capture generation at hop time, ingest, then
123+
* send only if /clear|/new has not bumped. Operator Enter must not use this.
124+
*/
125+
export function createLeftoverSend(
126+
args: CreateLeftoverSendArgs,
127+
): (text: string, attachments?: readonly PendingImageAttachment[]) => void {
128+
return createGenerationGatedHop({ ...args, hop: args.send });
129+
}

src/tui/runner.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ import { createCycleTextRecorder } from "../session/stream-journal.js";
187187
import { mountRunnerHost } from "./runner-host.js";
188188
import {
189189
createDeliveryGeneration,
190+
createLeftoverSend,
190191
createLiveSteerDeliver,
191192
routeQueuedDelivery,
192193
} from "./queued-delivery.js";
@@ -2296,7 +2297,25 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22962297
}),
22972298
interrupt,
22982299
deliver: routeQueuedDelivery({
2299-
send,
2300+
send: createLeftoverSend({
2301+
enqueue: sessionOps.enqueue,
2302+
ingest: (text, pending) =>
2303+
ingestOperatorPrompt(text, config.cwd, imageAttachmentFromPath, pending),
2304+
send: (text, pending) => {
2305+
sendAborted = false;
2306+
void agentProxy.send(userInboundMessage(text, pending)).catch(handleSendFailure);
2307+
},
2308+
recordSent: (text) => {
2309+
if (text.trim().length === 0) return;
2310+
void appendSentMessage(config.cwd, sessionId, text).catch((err: unknown) => {
2311+
tuiLogger.debug("sent-message append failed: {error}", {
2312+
error: err instanceof Error ? err.message : String(err),
2313+
});
2314+
});
2315+
},
2316+
captureGeneration: deliveryGeneration.capture,
2317+
onFailure: handleSendFailure,
2318+
}),
23002319
parentCycleLive: () => host.bridge.parentCycleLive,
23012320
deliverSteer: createLiveSteerDeliver({
23022321
enqueue: sessionOps.enqueue,

0 commit comments

Comments
 (0)