Implementation plan for reply and reply-with-quote context

This commit is contained in:
EugeneTes
2026-09-22 08:15:37 +00:00
parent 99dc906d4e
commit 90bcb6fd1f

View File

@@ -0,0 +1,516 @@
# Reply and quoted-reply context implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the owner replies to a Telegram message (or "reply with quote"s a substring), inject a `[Reply to your message: "…"]`-style header into the agent's next turn so it knows what's being answered.
**Architecture:** Extend the `InboundMessage` and `InboundDeps` shapes in `src/telegram/inbound.ts` to carry `reply_to_message`, `quote`, and `botUserId`. Add a pure `buildReplyHeader(reply, quote, botUserId)` helper in the same file that returns the header line (or `undefined`). Compose the header ahead of the existing forward header when both are present. Thread `bot.botInfo?.id` from `bridge.ts` into the deps. No changes to the debouncer, outbound, or agent host.
**Tech Stack:** TypeScript, Node 24, `grammy` (Telegram), `vitest`.
**Design spec:** `docs/superpowers/specs/2026-09-22-reply-context-design.md`
---
## File Structure
- **Modify** `src/telegram/inbound.ts` — extend `InboundMessage` and `InboundDeps`, add `RepliedMessage` and `Quote` types, add `buildReplyHeader()` helper, prepend the header before the forward header when composing the final text.
- **Modify** `src/telegram/inbound.test.ts` — new tests for every branch of the header helper and the composition ordering.
- **Modify** `src/telegram/bridge.ts` — pass `botUserId: bot.botInfo?.id` into `normalizeMessage` deps.
- **Modify** `README.md` — one bullet under "Talking to it", one new manual E2E item #19.
The header helper is small enough that a separate file would be premature — it lives alongside `describeForwardOrigin`, which is exactly the same shape and function.
---
## Task 1: Add types and a truncation helper
**Files:**
- Modify: `src/telegram/inbound.ts`
- [ ] **Step 1: Add `RepliedMessage`, `Quote`, and `MAX_REPLY_CONTEXT_CHARS` at the top of the file, next to `ForwardOrigin`**
Insert after the existing `ForwardOrigin` union (around `inbound.ts:10`):
```ts
export interface RepliedMessage {
from?: { id: number; is_bot?: boolean };
text?: string;
caption?: string;
photo?: Array<unknown>;
voice?: unknown;
audio?: { file_name?: string };
document?: { file_name?: string };
}
export interface Quote {
text: string;
is_manual?: boolean;
}
/** Longest replied-to text (or quote) we inline into the header before truncating with an ellipsis. */
export const MAX_REPLY_CONTEXT_CHARS = 500;
```
- [ ] **Step 2: Extend `InboundMessage` with `reply_to_message` and `quote`**
Change `inbound.ts`'s existing `InboundMessage` interface to add two optional fields at the end:
```ts
export interface InboundMessage {
text?: string;
caption?: string;
photo?: Array<{ file_id: string; width: number; height: number; file_size?: number }>;
voice?: { file_id: string; mime_type?: string; duration?: number };
audio?: { file_id: string; mime_type?: string; file_name?: string };
document?: { file_id: string; file_name?: string; mime_type?: string };
forward_origin?: ForwardOrigin;
reply_to_message?: RepliedMessage;
quote?: Quote;
}
```
- [ ] **Step 3: Extend `InboundDeps` with `botUserId`**
Change the existing `InboundDeps` interface to add one optional field:
```ts
export interface InboundDeps {
download(fileId: string): Promise<{ bytes: Buffer; filePath: string }>;
transcriber?: Transcriber;
workspaceDir: string;
now?: () => Date;
/** The bot's own user id, for labelling replies to bot vs owner messages. */
botUserId?: number;
}
```
- [ ] **Step 4: Typecheck**
Run: `npm run typecheck`
Expected: exits 0 (no `noEmit` errors). If it fails, the shape of an existing test's `InboundMessage`/`InboundDeps` object is now under-typed — but every new field is optional so this should pass.
- [ ] **Step 5: Commit**
```bash
git add src/telegram/inbound.ts
git commit -m "Add reply, quote, and botUserId types to inbound"
```
---
## Task 2: Write and pass tests for `buildReplyHeader`
**Files:**
- Modify: `src/telegram/inbound.ts`
- Modify: `src/telegram/inbound.test.ts`
- [ ] **Step 1: Add the failing tests for the header helper**
Append to `src/telegram/inbound.test.ts` after the last `describe(...)` block (i.e. at the end of the file). This starts a new `describe` block so it can co-exist with the existing `saveToInbox` block:
```ts
describe("buildReplyHeader", () => {
const BOT_ID = 42;
it("returns undefined when there is no reply and no quote", () => {
expect(buildReplyHeader(undefined, undefined, BOT_ID)).toBeUndefined();
});
it("labels a plain reply to a bot message with 'your message'", () => {
const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, text: "Which file?" }, undefined, BOT_ID);
expect(header).toBe('[Reply to your message: "Which file?"]');
});
it("labels a plain reply to the owner's own earlier message", () => {
const header = buildReplyHeader({ from: { id: 99, is_bot: false }, text: "Buy milk" }, undefined, BOT_ID);
expect(header).toBe('[Reply to your own earlier message: "Buy milk"]');
});
it("falls back to 'the earlier message' when `from` is absent", () => {
const header = buildReplyHeader({ text: "hello" }, undefined, BOT_ID);
expect(header).toBe('[Reply to the earlier message: "hello"]');
});
it("uses the quote text and 'quoting your message' wording for a reply-with-quote to the bot", () => {
const header = buildReplyHeader(
{ from: { id: BOT_ID, is_bot: true }, text: "The full assistant answer with lots of context." },
{ text: "assistant answer" },
BOT_ID,
);
expect(header).toBe('[Reply quoting your message: "assistant answer"]');
});
it("uses 'quoting your own earlier message' when the source is the owner's own", () => {
const header = buildReplyHeader(
{ from: { id: 99, is_bot: false }, text: "shopping list" },
{ text: "shopping" },
BOT_ID,
);
expect(header).toBe('[Reply quoting your own earlier message: "shopping"]');
});
it("treats an empty quote.text as if there were no quote (falls through to plain reply)", () => {
const header = buildReplyHeader(
{ from: { id: BOT_ID, is_bot: true }, text: "The answer" },
{ text: "" },
BOT_ID,
);
expect(header).toBe('[Reply to your message: "The answer"]');
});
it("describes a photo-only replied-to message", () => {
const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, photo: [{}] }, undefined, BOT_ID);
expect(header).toBe('[Reply to your message: "(photo)"]');
});
it("describes a voice-only replied-to message", () => {
const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, voice: {} }, undefined, BOT_ID);
expect(header).toBe('[Reply to your message: "(voice message)"]');
});
it("describes a document with a filename", () => {
const header = buildReplyHeader(
{ from: { id: BOT_ID, is_bot: true }, document: { file_name: "notes.txt" } },
undefined,
BOT_ID,
);
expect(header).toBe('[Reply to your message: "(document: notes.txt)"]');
});
it("describes an unnamed document", () => {
const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, document: {} }, undefined, BOT_ID);
expect(header).toBe('[Reply to your message: "(document)"]');
});
it("uses the caption instead of the media descriptor when both are present", () => {
const header = buildReplyHeader(
{ from: { id: BOT_ID, is_bot: true }, photo: [{}], caption: "the sunset" },
undefined,
BOT_ID,
);
expect(header).toBe('[Reply to your message: "the sunset"]');
});
it("truncates replied-to text longer than 500 characters with an ellipsis", () => {
const long = "a".repeat(600);
const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, text: long }, undefined, BOT_ID);
expect(header).toBe(`[Reply to your message: "${"a".repeat(499)}…"]`);
});
it("truncates a very long quote in the same way", () => {
const long = "b".repeat(600);
const header = buildReplyHeader(
{ from: { id: BOT_ID, is_bot: true }, text: "irrelevant" },
{ text: long },
BOT_ID,
);
expect(header).toBe(`[Reply quoting your message: "${"b".repeat(499)}…"]`);
});
it("labels as 'your message' when botUserId is missing but is_bot is true", () => {
const header = buildReplyHeader({ from: { id: 0, is_bot: true }, text: "hi" }, undefined, undefined);
expect(header).toBe('[Reply to your message: "hi"]');
});
it("falls back to '(message)' when the replied-to message has no usable fields", () => {
const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true } }, undefined, BOT_ID);
expect(header).toBe('[Reply to your message: "(message)"]');
});
});
```
Also update the top-of-file import to pull in `buildReplyHeader` and `MAX_REPLY_CONTEXT_CHARS` (the constant is exported so tests can reference the boundary if needed, though this batch only uses the function):
```ts
import { buildReplyHeader, type InboundDeps, normalizeMessage, saveToInbox } from "./inbound.js";
```
- [ ] **Step 2: Run the tests to confirm they fail**
Run: `npx vitest run src/telegram/inbound.test.ts`
Expected: FAIL — the tests reference `buildReplyHeader` which doesn't exist yet. Error like "buildReplyHeader is not a function" or a TS compilation error.
- [ ] **Step 3: Implement `buildReplyHeader` in `src/telegram/inbound.ts`**
Add at the end of `src/telegram/inbound.ts`, after the existing `errorText` helper:
```ts
/** Builds the `[Reply ...]` header line for the agent, or returns undefined when there is nothing to reply to. */
export function buildReplyHeader(
reply: RepliedMessage | undefined,
quote: Quote | undefined,
botUserId: number | undefined,
): string | undefined {
if (!reply) return undefined;
const isFromBot =
reply.from !== undefined &&
(botUserId !== undefined ? reply.from.id === botUserId : reply.from.is_bot === true);
const isFromOwner = reply.from !== undefined && !isFromBot;
const quoted = quote?.text ?? "";
if (quoted.length > 0) {
const who = isFromBot
? "your message"
: isFromOwner
? "your own earlier message"
: "the earlier message";
return `[Reply quoting ${who}: "${truncateReplyContext(quoted)}"]`;
}
const who = isFromBot
? "your message"
: isFromOwner
? "your own earlier message"
: "the earlier message";
const content = truncateReplyContext(replyContent(reply));
return `[Reply to ${who}: "${content}"]`;
}
function replyContent(reply: RepliedMessage): string {
if (reply.text && reply.text.length > 0) return reply.text;
if (reply.caption && reply.caption.length > 0) return reply.caption;
if (reply.photo && reply.photo.length > 0) return "(photo)";
if (reply.voice) return "(voice message)";
if (reply.audio) return reply.audio.file_name ? `(audio: ${reply.audio.file_name})` : "(audio)";
if (reply.document) return reply.document.file_name ? `(document: ${reply.document.file_name})` : "(document)";
return "(message)";
}
function truncateReplyContext(text: string): string {
const chars = Array.from(text);
if (chars.length <= MAX_REPLY_CONTEXT_CHARS) return text;
return `${chars.slice(0, MAX_REPLY_CONTEXT_CHARS - 1).join("")}…`;
}
```
- [ ] **Step 4: Run the tests to confirm they pass**
Run: `npx vitest run src/telegram/inbound.test.ts`
Expected: PASS — all new `buildReplyHeader` cases green, existing `normalizeMessage` and `saveToInbox` cases still green.
- [ ] **Step 5: Commit**
```bash
git add src/telegram/inbound.ts src/telegram/inbound.test.ts
git commit -m "Add buildReplyHeader helper with truncation and media descriptors"
```
---
## Task 3: Compose the reply header into `normalizeMessage` output
**Files:**
- Modify: `src/telegram/inbound.ts`
- Modify: `src/telegram/inbound.test.ts`
- [ ] **Step 1: Add failing integration tests to `normalizeMessage`**
Insert new test cases inside the existing `describe("normalizeMessage", ...)` block in `src/telegram/inbound.test.ts`, after the last existing case (the "labels forwarded photos even when there is no caption" test) but before the block closes:
```ts
it("prepends a reply header when the user replies to a bot message", async () => {
const r = await normalizeMessage(
{
text: "workspace/foo.csv",
reply_to_message: { from: { id: 42, is_bot: true }, text: "Which file did you mean?" },
},
deps({ botUserId: 42 }),
);
expect(r.input?.text).toBe('[Reply to your message: "Which file did you mean?"]\nworkspace/foo.csv');
});
it("uses 'your own earlier message' when replying to the owner's own message", async () => {
const r = await normalizeMessage(
{
text: "still true",
reply_to_message: { from: { id: 99, is_bot: false }, text: "the dog's name is Rex" },
},
deps({ botUserId: 42 }),
);
expect(r.input?.text).toBe('[Reply to your own earlier message: "the dog\'s name is Rex"]\nstill true');
});
it("uses the quote wording and text on reply-with-quote", async () => {
const r = await normalizeMessage(
{
text: "what does this mean?",
reply_to_message: { from: { id: 42, is_bot: true }, text: "long assistant explanation" },
quote: { text: "geothermal power plant" },
},
deps({ botUserId: 42 }),
);
expect(r.input?.text).toBe('[Reply quoting your message: "geothermal power plant"]\nwhat does this mean?');
});
it("puts the reply header before the forward header when both are present", async () => {
const r = await normalizeMessage(
{
text: "this one",
reply_to_message: { from: { id: 42, is_bot: true }, text: "which article?" },
forward_origin: { type: "channel", chat: { title: "Daily News" } },
},
deps({ botUserId: 42 }),
);
expect(r.input?.text).toBe('[Reply to your message: "which article?"]\n[Forwarded from Daily News]\nthis one');
});
it("keeps the reply header on a photo-only forwarded reply", async () => {
const r = await normalizeMessage(
{
photo: [{ file_id: "p2", width: 1, height: 1 }],
reply_to_message: { from: { id: 42, is_bot: true }, text: "which of these?" },
forward_origin: { type: "user", sender_user: { first_name: "Alice" } },
},
deps({ botUserId: 42 }),
);
expect(r.input?.text).toBe(
'[Reply to your message: "which of these?"]\n[Forwarded from Alice]\n(photo attached)',
);
});
it("adds a reply header on a photo-only reply with no body text", async () => {
const r = await normalizeMessage(
{
photo: [{ file_id: "p2", width: 1, height: 1 }],
reply_to_message: { from: { id: 42, is_bot: true }, text: "which of these?" },
},
deps({ botUserId: 42 }),
);
expect(r.input?.text).toBe('[Reply to your message: "which of these?"]\n(photo attached)');
});
```
- [ ] **Step 2: Run the tests to confirm they fail**
Run: `npx vitest run src/telegram/inbound.test.ts`
Expected: FAIL on the new `normalizeMessage` cases — output still equals the body without the header, e.g. actual `"workspace/foo.csv"` vs expected `"[Reply to your message: \"Which file did you mean?\"]\nworkspace/foo.csv"`.
- [ ] **Step 3: Wire the header into `normalizeMessage`**
Modify the tail of `normalizeMessage` in `src/telegram/inbound.ts`. Locate the existing forward-header block:
```ts
if (parts.length === 0 && images.length > 0) parts.push("(photo attached)");
let text = parts.join("\n\n");
if (msg.forward_origin && (text || images.length > 0)) {
const label = describeForwardOrigin(msg.forward_origin);
text = text ? `[Forwarded from ${label}]\n${text}` : `[Forwarded from ${label}]`;
}
return {
input: text ? { text, images: images.length > 0 ? images : undefined } : null,
transcript,
notes,
};
```
Replace with:
```ts
if (parts.length === 0 && images.length > 0) parts.push("(photo attached)");
let text = parts.join("\n\n");
if (msg.forward_origin && (text || images.length > 0)) {
const label = describeForwardOrigin(msg.forward_origin);
text = text ? `[Forwarded from ${label}]\n${text}` : `[Forwarded from ${label}]`;
}
const replyHeader = buildReplyHeader(msg.reply_to_message, msg.quote, deps.botUserId);
if (replyHeader && (text || images.length > 0)) {
text = text ? `${replyHeader}\n${text}` : replyHeader;
}
return {
input: text ? { text, images: images.length > 0 ? images : undefined } : null,
transcript,
notes,
};
```
The reply header is prepended after the forward header is prepended — so when both are set, the string ends up `[Reply ...]\n[Forwarded ...]\n<body>`, which matches the ordering the tests expect.
- [ ] **Step 4: Run the tests to confirm they pass**
Run: `npx vitest run src/telegram/inbound.test.ts`
Expected: PASS — all new `normalizeMessage` cases green, plus every existing case still green.
- [ ] **Step 5: Commit**
```bash
git add src/telegram/inbound.ts src/telegram/inbound.test.ts
git commit -m "Prepend reply header ahead of forward header in normalizeMessage"
```
---
## Task 4: Thread `botUserId` from `bridge.ts`
**Files:**
- Modify: `src/telegram/bridge.ts`
- [ ] **Step 1: Pass `bot.botInfo?.id` into the deps**
Locate the `bot.on("message", ...)` handler in `src/telegram/bridge.ts` (around `bridge.ts:110-129`). Modify the `normalizeMessage` call:
```ts
const result = await normalizeMessage(ctx.message, {
download: (fileId) => downloadFile(bot, config.telegramBotToken, fileId, deps.fetchFn),
transcriber: deps.transcriber,
workspaceDir: config.workspaceDir,
botUserId: bot.botInfo?.id,
});
```
`bot.botInfo` is populated by grammy once `bot.init()` (or `bot.start()`) has run. By the time this handler fires, both are done. The optional chaining plus the `is_bot` fallback in `buildReplyHeader` keep things safe if somehow it isn't.
- [ ] **Step 2: Typecheck**
Run: `npm run typecheck`
Expected: exits 0.
- [ ] **Step 3: Run the full test suite**
Run: `npm test`
Expected: all tests pass, including the ones added in Tasks 2 and 3.
- [ ] **Step 4: Commit**
```bash
git add src/telegram/bridge.ts
git commit -m "Pass bot user id into normalizeMessage for reply labelling"
```
---
## Task 5: README updates
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add the "Talking to it" bullet**
In `README.md`, find the "Talking to it" section (around README.md:17-25). After the paragraph starting "It can also send files back", add:
```markdown
Reply to any of my messages (or "reply with quote" a specific part) and I'll see what you're answering to — useful for coming back to something from earlier in the day.
```
- [ ] **Step 2: Add a manual E2E item**
At the end of the "Manual end-to-end checklist" list, after item 18, add:
```markdown
19. Reply to one of the bot's earlier messages with a follow-up like "what did you mean by that?" → the answer references that specific message, not the most recent one. Then "reply with quote" a specific phrase from a bot message → the answer references the quoted phrase.
```
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "Document reply / reply-with-quote support in the README"
```
---
## Self-review notes (for the executor)
- Every task above ends in a green test run or a typecheck plus a commit — no batching.
- The types added in Task 1 (`RepliedMessage`, `Quote`) are the exact same shapes the tests in Task 2 rely on. The `botUserId` field type on `InboundDeps` matches the value we thread through in Task 4 (`bot.botInfo?.id` returns `number | undefined`).
- The header helper name is stable across all tasks (`buildReplyHeader`). No aliasing.
- Truncation constant is a single exported symbol (`MAX_REPLY_CONTEXT_CHARS`) referenced from one place; tests use the numeric value directly, so a future change to the constant only requires updating the corresponding assertions.
- Ordering: forward header is prepended first, reply header second, resulting in the `[Reply ...]\n[Forwarded ...]\n<body>` order the tests expect.