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"); });