Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/app/services/session-pick-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
42 changes: 30 additions & 12 deletions src/bot/callbacks/session-callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -488,6 +490,25 @@ async function loadParentMessage(
}
}

async function sendPickQuote(
api: Context["api"],
chatId: number,
quote: QuotedNotification,
): Promise<void> {
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,
Expand All @@ -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 {
Expand All @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions tests/app/services/session-pick-reply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildSessionPickQuote,
findEligibleReply,
findLatestUserPrompt,
findPendingUserPrompt,
type SessionPickMessage,
} from "../../../src/app/services/session-pick-reply.js";

Expand Down Expand Up @@ -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(
[
Expand Down
14 changes: 8 additions & 6 deletions tests/bot/commands/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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: {
Expand Down Expand Up @@ -579,15 +579,17 @@ describe("bot/commands/sessions", () => {
const sent = (ctx.api.sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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");
});
Expand Down
Loading