Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5d7878c
Agent chat rail: read the chat's earlier prompts in the background
teamleaderleo Oct 26, 2026
f6262e0
feat(agent-chat): expose earlier prompt index paging
teamleaderleo Oct 27, 2026
bde8e2e
fix(agent-chat): preserve prompt index page state
teamleaderleo Oct 27, 2026
8885fc9
fix(agent-chat): propagate complete turn state across index pages
teamleaderleo Oct 27, 2026
1c1fd5d
fix(agent-chat): retain unknown boundaries and publish index dirtiness
teamleaderleo Oct 27, 2026
181d979
fix(agent-chat): clear continuation after failed turn results
teamleaderleo Oct 27, 2026
f91120b
Merge branch 'feat-cmux-next' into rail-opus-earlier-prompts-index
teamleaderleo Oct 27, 2026
f1f2604
Merge branch 'feat-cmux-next' into rail-opus-earlier-prompts-index
teamleaderleo Oct 27, 2026
8e78987
Merge feat-cmux-next into rail-opus-earlier-prompts-index
teamleaderleo Oct 28, 2026
108642c
Merge remote-tracking branch 'origin/feat-cmux-next' into rail-opus-e…
teamleaderleo Oct 30, 2026
2f0cb9a
fix(agent-chat): read the earlier prompt index only once the rail sub…
teamleaderleo Oct 30, 2026
4bb0f05
fix(agent-chat): the last rail unsubscribing stops the earlier prompt…
teamleaderleo Oct 30, 2026
42a5a1e
fix(agent-chat): a prompt sent now at the unread boundary is that pro…
teamleaderleo Oct 30, 2026
bb733c6
fix(agent-chat): the prompt index reads turn_started; stop drops its …
teamleaderleo Oct 30, 2026
76b7865
test(agent-chat): a prompt sent now while idle stays on the rail at e…
teamleaderleo Oct 30, 2026
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
177 changes: 144 additions & 33 deletions webviews/src/agent-session/acpmux/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ import {
pullRequestRowKey,
} from "./summary/transcriptPullRequests";
import { parseHarnessIconSpec, rememberHarnessIcons } from "../shared/harnessIcons";
import {
EarlierPrompts,
PROMPT_PAGE_BYTES,
PROMPT_PAGE_LIMIT,
TURN_KINDS,
type EarlierPromptsStore,
} from "./threadMinimap/earlierPrompts";
import { handoffPromptId, namesHandoffTurn } from "./cloudHandoffTurn";

export type AcpmuxHostConfig = {
Expand Down Expand Up @@ -1293,6 +1300,8 @@ export class AcpmuxDirectClient {
/// Each waiting prompt's author as its newest `queued` or `queue_updated` record named it (null:
/// none), kept while it waits, so acpmux's list (which names none) keeps it (mergeQueue).
private queueAuthors = new Map<string, Participant | null>();
/// The same, for the rail's prompts older than the transcript (earlierPrompts.ts).
private earlierSenders = new Set<string>();
/// The session list a page at a time: the newest page opens the chat, the rest follow (cx-2lv1k).
private readonly sessionPages = new SessionPager<Session>((method, params) => this.request(method, params));
private sessionReread: ReturnType<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -1648,6 +1657,50 @@ export class AcpmuxDirectClient {
/// The selection generation whose attach reply has landed; lag resync waits for it.
private attachedGeneration = -1;
private historyExhausted = false;
/// The chat's prompts older than the held transcript, read in the page's idle time for the rail
/// (threadMinimap/earlierPrompts.ts). Stops once read to the start, on a chat switch or a close.
private earlierPrompts = new EarlierPrompts(
async (sessionId, beforeSeq) => {
const result = await this.typed("_acpmux/events", {
sessionId,
beforeSeq,
limit: PROMPT_PAGE_LIMIT,
maxBytes: PROMPT_PAGE_BYTES,
kinds: TURN_KINDS,
});
return { events: records(result?.events), hasMore: result?.hasMore === true };
},
(record) => this.earlierPromptRow(record as EventRecord),
() => {
if (!this.closed) for (const listener of this.earlierListeners) listener();
},
);
private earlierListeners = new Set<() => void>();
/// Where the chat's earlier prompts start (below the attach page's oldest record). Nothing is read
/// until the rail subscribes to `earlier`: a pane with no rail listening pays no paging and holds
/// no index.
private earlierFrom?: { sessionId: string; beforeSeq: number };
private readEarlier(): void {
const from = this.earlierFrom;
if (!from || this.closed || this.earlierListeners.size === 0 || from.sessionId !== this.selectedSessionId) return;
this.earlierPrompts.start(from.sessionId, from.beforeSeq);
}
/// The rail's earlier prompts, as their own store (earlierPrompts.ts): a page that lands renders the
/// rail alone, not the pane. They are the prompts above the first one drawn (the open turn's opener,
/// when the held events start mid-turn); a snapshot that moves that bound renders the rail anyway.
readonly earlier: EarlierPromptsStore = {
subscribe: (listener) => {
this.earlierListeners.add(listener);
this.readEarlier();
return () => {
this.earlierListeners.delete(listener);
// The rail went away: stop reading and drop the index. A later subscriber reads again from
// `earlierFrom`.
if (this.earlierListeners.size === 0) this.earlierPrompts.stop();
};
},
get: () => this.earlierPrompts.shown(this.turnOpener?.seq ?? this.firstSeq),
};
/// Bumped when the held transcript's older end moves on its own: a kept tail dropped because the
/// attach page did not continue it. An older page in flight from before belongs to a transcript
/// the chat no longer holds, so it is dropped instead of merged above the new one.
Expand Down Expand Up @@ -2110,6 +2163,9 @@ export class AcpmuxDirectClient {
this.turnOpener = undefined;
this.turnPrompt = undefined;
this.historyExhausted = false;
this.earlierPrompts.stop();
this.earlierFrom = undefined;
this.earlierSenders.clear();
this.olderRetryAt = 0;
// Nothing is drawn, so nothing drawn came from a kept tail: the next open says for itself.
this.drewKeptTail = false;
Expand Down Expand Up @@ -3633,6 +3689,12 @@ export class AcpmuxDirectClient {
this.queue = mergeQueue(this.queue, detail.queue ?? [], this.queueAuthors);
// acpmux holds nothing older than the page: the pane offers no more history.
if (result?.hasMore === false) this.historyExhausted = true;
// The rail shows every prompt of the chat: the ones older than the page come in the background,
// once the rail listens (`earlier.subscribe`).
else if (this.firstSeq !== undefined && this.firstSeq > 1) {
this.earlierFrom = { sessionId, beforeSeq: this.firstSeq };
this.readEarlier();
}
this.attachedGeneration = generation;
// The machine answered: these rows are its chat now, not the Mac's copy.
this.peerCopy = undefined;
Expand Down Expand Up @@ -3893,7 +3955,60 @@ export class AcpmuxDirectClient {
/// `sender -> receiver: …` from the person's words (conversation/incomingMessage.ts). This chat
/// is the summary, or its listed entry while the summary is not in yet (a kept tail's replay).
/// A sender no listed chat names is kept, so a list that names it later reads the rows again.
private incomingContext(): IncomingContext {
/// What a `user_message` record's row shows: the transcript's prompt rows, and the rail's prompts
/// older than the transcript (earlierPrompts.ts), which must read the same.
private shownPrompt(msg: any, unknown = this.unknownSenders) {
// A task's result is not the person's prompt, shown or not: it never enters prompt recall.
const taskResult = offerResult(msg);
const received = offersShown() ? taskResult : undefined;
// Another agent's message or harness bookkeeping (conversation/incomingMessage.ts): the row
// keeps the sender's words, or the typed prompt without the bookkeeping. Bookkeeping alone
// keeps an empty row that draws nothing, so the answer it brought stays its own turn
// (turns.ts). Retry resends a message as the agent got it (`resend`); a notice, like
// bookkeeping, is the harness's, not a prompt to send again, so it offers no Retry.
const raw = String(msg.text ?? "");
// A prompt with nothing to read and no image draws nothing, never an empty bubble: a blank
// text, a record without `text` (one acpmux left out of the page for its size, paging.rs
// `fit`, keeps only `_meta`), or a steer that carried none. It stays a turn's start, as
// bookkeeping does. A prompt of images alone keeps its row: the images are what it says.
// acpmux's own continuation of a failed turn or subagent (its retry, automatic or the Retry
// button's) is not the person's prompt: it draws nothing either (subagents/retryState.ts).
const blank = !received && (isRetryContinuation(msg) || (!raw.trim() && promptImages(msg.images).length === 0));
const incoming = received
? undefined
: blank
? ({ kind: "hidden" } as const)
: incomingPrompt(raw, msg, this.incomingContext(unknown));
const shown =
incoming?.kind === "message"
? { text: incoming.message.text, incoming, resend: raw }
: incoming?.kind === "notice"
? { text: incoming.text, incoming }
: incoming?.kind === "hidden"
? { text: "", incoming }
: { text: incoming?.kind === "prompt" ? incoming.text : raw };
const untyped = taskResult !== undefined || (incoming !== undefined && incoming.kind !== "prompt");
return { taskResult, received, incoming, raw, shown, untyped };
}

/// A prompt older than the transcript that opens a turn (earlierPrompts.ts TurnStarts), as the rail
/// shows it: the row its turn's prompt will be, with the promptId the rail keys it on (turnKey).
private earlierPromptRow(record: EventRecord): AcpmuxRow | undefined {
if (record.kind !== "user_message") return undefined;
const msg = (record.msg ?? {}) as any;
const { shown } = this.shownPrompt(msg, this.earlierSenders);
const promptId = typeof msg.promptId === "string" ? msg.promptId : undefined;
return {
id: `user-${record.seq}`,
version: 1,
at: record.at,
kind: "user",
text: shown.text,
...(promptId ? { promptId } : {}),
};
}

private incomingContext(unknown = this.unknownSenders): IncomingContext {
return {
isThisChat: (name) => {
const listed = this.sessions.find((session) => session.sessionId === this.selectedSessionId);
Expand All @@ -3904,7 +4019,7 @@ export class AcpmuxDirectClient {
},
isChat: (name) => {
const known = this.sessions.some((session) => namesChat(session, name));
if (!known) this.unknownSenders.add(name);
if (!known) unknown.add(name);
return known;
},
};
Expand All @@ -3915,8 +4030,15 @@ export class AcpmuxDirectClient {
/// queue keeps its live order and text; a prompt waiting in it whose `queued` record still holds
/// that text is read again too, unless the person edited it since (an edit makes it theirs).
private recheckSenders(added: readonly Session[]): void {
// The rail's prompts older than the transcript are read again on their own senders, so they show
// what their rows will once loaded.
const named = (names: Set<string>) => [...names].some((name) => added.some((session) => namesChat(session, name)));
if (this.earlierSenders.size > 0 && named(this.earlierSenders)) {
this.earlierSenders.clear();
this.earlierPrompts.refresh();
}
if (this.unknownSenders.size === 0 || !this.selectedSessionId || this.events.length === 0) return;
if (![...this.unknownSenders].some((name) => added.some((session) => namesChat(session, name)))) return;
if (!named(this.unknownSenders)) return;
this.rebuildKeepingLiveState();
const queued = new Map<string, any>();
for (const event of [...this.events].sort((a, b) => a.seq - b.seq)) {
Expand Down Expand Up @@ -4680,36 +4802,7 @@ export class AcpmuxDirectClient {
this.planCalls.clear();
}
this.streamingActivity = undefined;
// A task's result is not the person's prompt, shown or not: it never enters prompt recall.
const taskResult = offerResult(msg);
const received = offersShown() ? taskResult : undefined;
// Another agent's message or harness bookkeeping (conversation/incomingMessage.ts): the row
// keeps the sender's words, or the typed prompt without the bookkeeping. Bookkeeping alone
// keeps an empty row that draws nothing, so the answer it brought stays its own turn
// (turns.ts). Retry resends a message as the agent got it (`resend`); a notice, like
// bookkeeping, is the harness's, not a prompt to send again, so it offers no Retry.
const raw = String(msg.text ?? "");
// A prompt with nothing to read and no image draws nothing, never an empty bubble: a blank
// text, a record without `text` (one acpmux left out of the page for its size, paging.rs
// `fit`, keeps only `_meta`), or a steer that carried none. It stays a turn's start, as
// bookkeeping does. A prompt of images alone keeps its row: the images are what it says.
// acpmux's own continuation of a failed turn or subagent (its retry, automatic or the Retry
// button's) is not the person's prompt: it draws nothing either (subagents/retryState.ts).
const blank = !received && (isRetryContinuation(msg) || (!raw.trim() && promptImages(msg.images).length === 0));
const incoming = received
? undefined
: blank
? ({ kind: "hidden" } as const)
: incomingPrompt(raw, msg, this.incomingContext());
const shown =
incoming?.kind === "message"
? { text: incoming.message.text, incoming, resend: raw }
: incoming?.kind === "notice"
? { text: incoming.text, incoming }
: incoming?.kind === "hidden"
? { text: "", incoming }
: { text: incoming?.kind === "prompt" ? incoming.text : raw };
const untyped = taskResult !== undefined || (incoming !== undefined && incoming.kind !== "prompt");
const { received, raw, shown, untyped } = this.shownPrompt(msg);
// Its queued line, if the result waited, gives way to this prompt's.
if (received) this.rows.delete(`received-${received.offerId}`);
if (received) this.receivedOffers.add(received.offerId);
Expand Down Expand Up @@ -7581,6 +7674,23 @@ export class AcpmuxDirectClient {
this.emit("history");
}

/// Materializes the transcript through an indexed prompt so the rail can hand the existing
/// history/anchor path a real row. This deliberately delegates every page to `loadOlder`: its
/// in-flight, selection-generation and history-epoch guards remain the single source of truth.
async loadThrough(seq: number): Promise<void> {
if (!Number.isFinite(seq) || seq < 1) return;
const sessionId = this.selectedSessionId;
const generation = this.selectionGeneration;
for (let pages = 0; pages < OLDER_PAGES_PER_ASK * 200; pages += 1) {
if (this.closed || generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return;
if (this.rows.has(`user-${seq}`) || this.historyExhausted || (this.firstSeq ?? 1) <= 1) return;
const before = this.firstSeq;
await this.loadOlder();
if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return;
if (before === this.firstSeq && !this.loadingOlder) return;
}
}

/// Folds whole turns older than every held event into rows above them, without folding the held
/// events again: the cost of a page stays the page's however much history is loaded. The fold
/// starts from a clean turn and leaves the live state (an open turn, its stream and typing row,
Expand Down Expand Up @@ -7773,6 +7883,7 @@ export class AcpmuxDirectClient {
this.snapshotHold = undefined;
hold?.release();
this.sessionPages.stop();
this.earlierPrompts.stop();
clearTimeout(this.readAheadTimer);
this.readAheadTimer = undefined;
this.readAheadQueue = [];
Expand Down
Loading
Loading