From 735f81c7255481c8dfb1913f1045c7473ae3bf54 Mon Sep 17 00:00:00 2001 From: Anton Gladkov Date: Sat, 17 Oct 2026 11:29:32 +0300 Subject: [PATCH] fix(sessions): show the waiting input when a picked session is still replying Picking a session while it is still replying showed the previous completed exchange, so a message just sent appeared nowhere and looked lost. The pick transcript now also sends the newest user input created after the shown reply started, once, right after the reply. The waiting input is detected by creation time, so it behaves the same on V1 and V2. --- PRODUCT.md | 2 +- src/app/services/session-pick-reply.ts | 17 +++++++ src/bot/callbacks/session-callback-handler.ts | 42 +++++++++++----- tests/app/services/session-pick-reply.test.ts | 48 +++++++++++++++++++ tests/bot/commands/sessions.test.ts | 14 +++--- 5 files changed, 104 insertions(+), 19 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index c3079f032..389e1bfa3 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -44,7 +44,7 @@ No public inbound ports are required for normal usage. ### Session management - Fetch last N sessions (name + date) -- Select an existing session, show the last user input as a quote and the last assistant reply in full, then follow its live updates +- Select an existing session, show the last user input as a quote and the last assistant reply in full (plus the newest input still waiting for its reply), then follow its live updates - Browse up to `SESSIONS_LIST_LIMIT` recent root sessions across projects and git worktrees with running, idle, question and permission status (a question or permission request a subagent is waiting on counts for its root session); select one to switch project and follow it, including after detach - Switching to an existing session adopts the agent, model, and variant it last ran with - Create a new session diff --git a/src/app/services/session-pick-reply.ts b/src/app/services/session-pick-reply.ts index e4103f1eb..12ad23c1d 100644 --- a/src/app/services/session-pick-reply.ts +++ b/src/app/services/session-pick-reply.ts @@ -110,6 +110,23 @@ export function findMessageById( return messages.find((message) => message.info.id === messageId) ?? null; } +/** + * The newest user input the transcript does not already cover: a message created after the + * shown reply started, still waiting for its own reply. Compared by time rather than by the + * reply's parent, because the V2 adapter carries no parent link. + */ +export function findPendingUserPrompt( + messages: SessionPickMessage[], + shownReplyCreated: number, +): SessionPickMessage | null { + const latest = findLatestUserPrompt(messages); + if (!latest) { + return null; + } + + return (latest.info.time?.created ?? 0) > shownReplyCreated ? latest : null; +} + export function buildSessionPickQuote(message: SessionPickMessage): QuotedNotification | null { const text = extractText(message.parts); const body = text ?? fileLines(message.parts).join("\n"); diff --git a/src/bot/callbacks/session-callback-handler.ts b/src/bot/callbacks/session-callback-handler.ts index d4bdfe71d..8d90b5758 100644 --- a/src/bot/callbacks/session-callback-handler.ts +++ b/src/bot/callbacks/session-callback-handler.ts @@ -28,8 +28,10 @@ import { findEligibleReply, findLatestUserPrompt, findMessageById, + findPendingUserPrompt, type SessionPickMessage, } from "../../app/services/session-pick-reply.js"; +import type { QuotedNotification } from "../../app/services/quoted-notification.js"; import { buildSessionSelectionMenuView, parseBackgroundSessionCallback, @@ -488,6 +490,25 @@ async function loadParentMessage( } } +async function sendPickQuote( + api: Context["api"], + chatId: number, + quote: QuotedNotification, +): Promise { + await waitForSessionPickSend(); + try { + await sendBotText({ + api, + chatId, + text: quote.text, + rawFallbackText: quote.rawFallbackText, + format: "markdown_v2", + }); + } catch (err) { + logger.error("[Sessions] Failed to send the session pick quote:", err); + } +} + async function sendSessionPickTranscript( api: Context["api"], chatId: number, @@ -510,18 +531,7 @@ async function sendSessionPickTranscript( const quote = quoteSource ? buildSessionPickQuote(quoteSource) : null; if (quote) { - await waitForSessionPickSend(); - try { - await sendBotText({ - api, - chatId, - text: quote.text, - rawFallbackText: quote.rawFallbackText, - format: "markdown_v2", - }); - } catch (err) { - logger.error("[Sessions] Failed to send the last user input quote:", err); - } + await sendPickQuote(api, chatId, quote); } else if (!reply) { await waitForSessionPickSend(); try { @@ -540,6 +550,14 @@ async function sendSessionPickTranscript( await waitForSessionPickSend(); await sendRenderedBotPart({ api, chatId, part, options: AGENT_TEXT_LINK_PREVIEW_OPTIONS }); } + + // A message sent after the shown reply started is still waiting for its own reply: show it + // too, so the picked session does not look like the message never went through. + const pendingPrompt = messages ? findPendingUserPrompt(messages, reply.created) : null; + const pendingQuote = pendingPrompt ? buildSessionPickQuote(pendingPrompt) : null; + if (pendingQuote) { + await sendPickQuote(api, chatId, pendingQuote); + } } async function loadLatestAssistantResponse( diff --git a/tests/app/services/session-pick-reply.test.ts b/tests/app/services/session-pick-reply.test.ts index 79dadf671..6aa11fad1 100644 --- a/tests/app/services/session-pick-reply.test.ts +++ b/tests/app/services/session-pick-reply.test.ts @@ -3,6 +3,7 @@ import { buildSessionPickQuote, findEligibleReply, findLatestUserPrompt, + findPendingUserPrompt, type SessionPickMessage, } from "../../../src/app/services/session-pick-reply.js"; @@ -63,6 +64,53 @@ describe("session pick reply", () => { expect(findLatestUserPrompt(messages)?.info.id).toBe("user-1"); }); + it("returns the newest prompt when it was created after the shown reply", () => { + const messages = [ + message("user", "answered prompt", 1, { id: "user-old" }), + message("assistant", "reply", 2, { + id: "assistant-1", + parentID: "user-old", + time: { created: 2, completed: 3 }, + }), + message("user", "waiting prompt", 4, { id: "user-new" }), + ]; + + expect(findPendingUserPrompt(messages, 2)?.info.id).toBe("user-new"); + }); + + it("returns the newest prompt when several were created after the shown reply", () => { + const messages = [ + message("user", "answered prompt", 1, { id: "user-old" }), + message("assistant", "reply", 2, { + id: "assistant-1", + parentID: "user-old", + time: { created: 2, completed: 3 }, + }), + message("user", "first waiting prompt", 4, { id: "user-mid" }), + message("user", "second waiting prompt", 5, { id: "user-new" }), + ]; + + expect(findPendingUserPrompt(messages, 2)?.info.id).toBe("user-new"); + }); + + it("returns nothing when the newest prompt predates the shown reply", () => { + const messages = [ + message("user", "the prompt", 1, { id: "user-1" }), + message("assistant", "reply", 2, { + id: "assistant-1", + parentID: "user-1", + time: { created: 2, completed: 3 }, + }), + ]; + + expect(findPendingUserPrompt(messages, 2)).toBeNull(); + expect(findPendingUserPrompt(messages, 3)).toBeNull(); + }); + + it("returns nothing when the session has no user prompt", () => { + expect(findPendingUserPrompt([message("assistant", "reply", 1)], 1)).toBeNull(); + }); + it("drops an in-flight reply while the session is busy", () => { const selected = findEligibleReply( [ diff --git a/tests/bot/commands/sessions.test.ts b/tests/bot/commands/sessions.test.ts index 10ddd6b05..8531fa353 100644 --- a/tests/bot/commands/sessions.test.ts +++ b/tests/bot/commands/sessions.test.ts @@ -516,7 +516,7 @@ describe("bot/commands/sessions", () => { ); }); - it("pages past a newer prompt and shows the finished reply it answers", async () => { + it("pages past a newer prompt and shows the finished reply it answers plus the waiting prompt", async () => { mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null, @@ -529,7 +529,7 @@ describe("bot/commands/sessions", () => { }, parts: index === 19 - ? [{ type: "text", text: "unanswered prompt" }] + ? [{ type: "text", text: "newer prompt" }] : [{ type: "tool" }], })); mocked.sessionMessagesMock.mockReset(); @@ -543,7 +543,7 @@ describe("bot/commands/sessions", () => { role: "user", time: { created: 100 }, }, - parts: [{ type: "text", text: "answered prompt" }], + parts: [{ type: "text", text: "older prompt" }], }, { info: { @@ -579,15 +579,17 @@ describe("bot/commands/sessions", () => { const sent = (ctx.api.sendMessage as ReturnType).mock.calls.map( (call) => String(call[1]), ); - expect(sent.some((text) => text.includes("answered prompt"))).toBe(true); + expect(sent.some((text) => text.includes("older prompt"))).toBe(true); expect(sent.some((text) => text.includes("finished reply"))).toBe(true); - expect(sent.some((text) => text.includes("unanswered prompt"))).toBe(false); expect(sent.some((text) => text.includes("Recent messages:"))).toBe(false); + const replyIndex = sent.findIndex((text) => text.includes("finished reply")); + const waitingIndex = sent.findIndex((text) => text.includes("newer prompt")); + expect(waitingIndex).toBeGreaterThan(replyIndex); expect(safeBackgroundTaskMock).not.toHaveBeenCalled(); const sendCalls = (ctx.api.sendMessage as ReturnType).mock.calls; const replyCall = sendCalls.find((call) => String(call[1]).includes("finished reply")); - const quoteCall = sendCalls.find((call) => String(call[1]).includes("answered prompt")); + const quoteCall = sendCalls.find((call) => String(call[1]).includes("older prompt")); expect(replyCall?.[2]).toMatchObject({ link_preview_options: { is_disabled: false } }); expect(quoteCall?.[2]).not.toHaveProperty("link_preview_options"); });