From 2950dafd1b9a082ef72af746a6e05f379fd357fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 04:40:59 +0000 Subject: [PATCH 1/9] Slice 0 (#24): gbot_thread summary plus cursor by default, text on full:true Co-authored-by: Zack Jackson --- .changeset/potato-thread-summary.md | 5 ++ plugin/src/gbot.ts | 13 ++++ plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 22 +++++-- plugin/src/skills/talk-to-grok-bot/SKILL.md | 2 +- plugin/tests/route-unit/tools.test.ts | 62 +++++++++++++++++-- 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 .changeset/potato-thread-summary.md diff --git a/.changeset/potato-thread-summary.md b/.changeset/potato-thread-summary.md new file mode 100644 index 0000000..b9926c9 --- /dev/null +++ b/.changeset/potato-thread-summary.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Slice 0 of epic #24: `gbot_thread` returns a short summary plus an opaque client-held `cursor` (last entry id) by default and withholds per-entry text; pass `full:true` to read entry text as before. Halves default poll token cost without touching `gbot history`, auth, or approval handling. diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index ac4be59..2a82484 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -111,3 +111,16 @@ export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; l return entry; }); }; + +/** + * Opaque client-held watermark for a thread read: the id of the last entry with + * one, or '' when the tail is empty or entries carry no ids. Slice 1 will accept + * this back as `after`; until then just hold it, never send it anywhere. + */ +export const threadCursor = (entries: readonly Pick[]): string => { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const id = entries[index]?.id; + if (typeof id === 'string' && id !== '') return id; + } + return ''; +}; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index a242ce2..1c18410 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -8,6 +8,7 @@ import { getTranscriptTail, summarizeTarget, targetSchema, + threadCursor, transcriptEntries, withRedactedErrors, } from '../../../gbot.js'; @@ -16,7 +17,7 @@ export default defineTool( { annotations: { readOnlyHint: true }, description: - 'Read the most recent messages in a Grok Bot bot or group thread, like `gbot thread`. Use it to collect the reply to a gbot_send.', + 'Read the most recent messages in a Grok Bot bot or group thread, like `gbot thread`. By default returns a short summary plus a cursor and withholds entry text; pass full:true to read the text. Use it to collect the reply to a gbot_send.', inputJsonSchema: { additionalProperties: false, properties: { @@ -43,15 +44,28 @@ export default defineTool( full: z.boolean().default(false), target: z.string().min(1), }), - resultSchema: z.object({ entries: z.array(entrySchema), target: targetSchema }), + resultSchema: z.object({ cursor: z.string(), entries: z.array(entrySchema), target: targetSchema }), title: 'Read a Grok Bot thread', }, async ({ limit, target, full }) => { const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit)); - const value = { entries: transcriptEntries(tail.transcript, { full, limit }), target: summarizeTarget(tail.target) }; + const entries = transcriptEntries(tail.transcript, { full, limit }); + const cursor = threadCursor(entries); + const summarized = summarizeTarget(tail.target); + const value = { cursor, entries, target: summarized }; + const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries. cursor ${cursor === '' ? '(none)' : cursor}.`; + // Default Agent.Text stays a short summary: dumping every entry here doubles + // the token cost of the structured value. full:true keeps the per-entry text. + if (!full) { + return ( + + {`${summary} Entry text withheld; pass full:true to read it.`} + + ); + } return ( - {`${value.target.kind} ${value.target.name}: ${value.entries.length} entries.`} + {summary} {value.entries.map((entry, index) => ( {`[${entry.role ?? entry.kind}] ${entry.text}`} ))} diff --git a/plugin/src/skills/talk-to-grok-bot/SKILL.md b/plugin/src/skills/talk-to-grok-bot/SKILL.md index f966dc4..4ad2ad8 100644 --- a/plugin/src/skills/talk-to-grok-bot/SKILL.md +++ b/plugin/src/skills/talk-to-grok-bot/SKILL.md @@ -16,7 +16,7 @@ Do not ping a bot for work you can finish yourself. Replies are asynchronous — ## How 1. `gbot_send` with `target` (name or id) and `message` (first line: who you are + what you need). -2. Later, `gbot_thread` with the same `target` (`limit` defaults to 40). Bot replies are `send-message` entries; yours are `message` with `role: user`. +2. Later, `gbot_thread` with the same `target` (`limit` defaults to 40). It returns a short summary plus a `cursor` by default; pass `full: true` to read entry text. Bot replies are `send-message` entries; yours are `message` with `role: user`. List targets with `gbot bots list` / `gbot groups list` when the name is ambiguous. diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index fbba0c5..18ba8a0 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -26,10 +26,18 @@ const roster = { { id: 'bot-5', isGroup: false, name: 'Noreceipt' }, { id: 'bot-6', isGroup: false, name: 'Big' }, { id: 'bot-7', isGroup: false, name: 'Meta' }, + { id: 'bot-8', isGroup: false, name: 'Noisy' }, ], }; const transcripts: Record = { 'bot-1': { entries: [], nextBeforeSeq: 0 }, + 'bot-8': { + entries: Array.from({ length: 60 }, (_, index) => ({ + id: `n${index + 1}`, + kind: 'message', + text: `update ${index + 1} ${'y'.repeat(390)}`, + })), + }, 'bot-2': { messages: [ { content: 'ignored when text is set', id: 'l1', text: 'direct text' }, @@ -143,13 +151,14 @@ describe('grok-bot MCP server', () => { expect(calls[1]?.body.clientNonce).toMatch(/^[0-9a-f-]{36}$/u); }); - it('gbot_thread normalizes user and bot entries and forwards the limit', async () => { + it('gbot_thread returns a short summary plus cursor by default and keeps entry text for full:true', async () => { const result = await invokeMcpTool('gbot_thread', { input: { limit: 3, target: 'Launch' }, server: 'grok-bot', }); expect(result.isError).toBe(false); expect(result.structuredContent).toEqual({ + cursor: 't3', entries: [ { id: 't1', kind: 'message', role: 'user', text: 'hello from the test', truncated: false, fullLength: 19, timestampMs: 1 }, { id: 't2', kind: 'send-message', text: 'reply', truncated: false, fullLength: 5 }, @@ -158,17 +167,30 @@ describe('grok-bot MCP server', () => { target: { id: 'grp-1', kind: 'group', name: 'Launch' }, }); expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); - expect(contentText(result.content)).toContain('[user] hello from the test'); - expect(contentText(result.content)).toContain('[send-message] reply'); + const summary = contentText(result.content); + expect(summary).toContain('group Launch: 3 entries. cursor t3.'); + expect(summary).toContain('pass full:true'); + expect(summary).not.toContain('hello from the test'); + expect(summary).not.toContain('reply'); + + const full = await invokeMcpTool('gbot_thread', { + input: { full: true, limit: 3, target: 'Launch' }, + server: 'grok-bot', + }); + expect(full.isError).toBe(false); + expect(contentText(full.content)).toContain('[user] hello from the test'); + expect(contentText(full.content)).toContain('[send-message] reply'); }); it('gbot_thread defaults the limit to 40 like the CLI and reads the other transcript shapes', async () => { const empty = await invokeMcpTool('gbot_thread', { input: { target: 'General' }, server: 'grok-bot' }); expect(calls[1]?.body).toEqual({ id: 'bot-1', limit: 40 }); - expect(empty.structuredContent).toEqual({ entries: [], target: { id: 'bot-1', kind: 'bot', name: 'General' } }); + expect(empty.structuredContent).toEqual({ cursor: '', entries: [], target: { id: 'bot-1', kind: 'bot', name: 'General' } }); + expect(contentText(empty.content)).toContain('bot General: 0 entries. cursor (none).'); const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); expect(legacy.structuredContent).toMatchObject({ + cursor: 'l4', entries: [ { id: 'l1', kind: 'message', text: 'direct text', truncated: false, fullLength: 11 }, { id: 'l2', kind: 'note', text: 'part one\npart two\npart three', truncated: false, fullLength: 28 }, @@ -176,8 +198,11 @@ describe('grok-bot MCP server', () => { { id: 'l4', kind: 'message', text: `${'x'.repeat(399)}…`, truncated: true, fullLength: 450 }, ], }); - expect(contentText(legacy.content)).toContain('…'); - expect(contentText(legacy.content)).not.toContain('x'.repeat(450)); + const legacySummary = contentText(legacy.content); + expect(legacySummary).toContain('bot Legacy: 4 entries. cursor l4.'); + expect(legacySummary).not.toContain('direct text'); + expect(legacySummary).not.toContain('…'); + expect(legacySummary).not.toContain('x'.repeat(450)); }); it('gbot_thread recovers a complete long reply with full:true and normalizes malformed entries', async () => { @@ -193,6 +218,7 @@ describe('grok-bot MCP server', () => { const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Odd' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ + cursor: 'o3', entries: [ { id: 'o1', kind: 'note', text: '5', truncated: false, fullLength: 1 }, { id: 'o2', kind: 'mystery', text: '', truncated: false, fullLength: 0 }, @@ -200,6 +226,29 @@ describe('grok-bot MCP server', () => { ], target: { id: 'bot-4', kind: 'bot', name: 'Odd' }, }); + expect(contentText(odd.content)).toContain('bot Odd: 3 entries. cursor o3.'); + }); + + it('gbot_thread default summary stays small on a 60-entry tail while full:true reads the text', async () => { + const summary = await invokeMcpTool('gbot_thread', { input: { limit: 60, target: 'Noisy' }, server: 'grok-bot' }); + expect(summary.isError).toBe(false); + const structured = summary.structuredContent as { cursor: string; entries: unknown[] }; + expect(structured.entries).toHaveLength(60); + expect(structured.cursor).toBe('n60'); + const summaryText = contentText(summary.content); + expect(summaryText).toContain('60 entries. cursor n60.'); + expect(summaryText).not.toContain('update 1'); + + const full = await invokeMcpTool('gbot_thread', { + input: { full: true, limit: 60, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(full.isError).toBe(false); + const fullText = contentText(full.content); + expect(fullText).toContain('update 1'); + // No-op poll token cost ≪ full tail. + expect(summaryText.length).toBeLessThan(300); + expect(fullText.length).toBeGreaterThan(10000); }); it('gbot_send stays unknown when the gateway confirms no receipt', async () => { @@ -244,6 +293,7 @@ describe('grok-bot MCP server', () => { const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ + cursor: `${'i'.repeat(200)}…`, entries: [ { id: `${'i'.repeat(200)}…`, From 87d1841abb9fec9038e0ea29affcab5ff713a5f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 04:48:39 +0000 Subject: [PATCH 2/9] Fix gbot_thread default result token leak Co-authored-by: Zack Jackson --- .changeset/potato-thread-summary.md | 5 --- plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 12 +++--- plugin/src/skills/talk-to-grok-bot/SKILL.md | 2 +- plugin/tests/route-unit/tools.test.ts | 37 ++++++++++--------- 4 files changed, 26 insertions(+), 30 deletions(-) delete mode 100644 .changeset/potato-thread-summary.md diff --git a/.changeset/potato-thread-summary.md b/.changeset/potato-thread-summary.md deleted file mode 100644 index b9926c9..0000000 --- a/.changeset/potato-thread-summary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"grok-bot-cli": patch ---- - -Slice 0 of epic #24: `gbot_thread` returns a short summary plus an opaque client-held `cursor` (last entry id) by default and withholds per-entry text; pass `full:true` to read entry text as before. Halves default poll token cost without touching `gbot history`, auth, or approval handling. diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 1c18410..7e1a999 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -23,7 +23,7 @@ export default defineTool( properties: { limit: { default: 40, - description: 'How many trailing entries to return (1-200). Each entry text is capped at 400 characters.', + description: 'How many trailing entries to inspect (1-200). Entries are returned only with full:true.', type: 'number', }, full: { @@ -44,7 +44,7 @@ export default defineTool( full: z.boolean().default(false), target: z.string().min(1), }), - resultSchema: z.object({ cursor: z.string(), entries: z.array(entrySchema), target: targetSchema }), + resultSchema: z.object({ cursor: z.string(), entries: z.array(entrySchema).optional(), target: targetSchema }), title: 'Read a Grok Bot thread', }, async ({ limit, target, full }) => { @@ -52,10 +52,10 @@ export default defineTool( const entries = transcriptEntries(tail.transcript, { full, limit }); const cursor = threadCursor(entries); const summarized = summarizeTarget(tail.target); - const value = { cursor, entries, target: summarized }; + const value = full ? { cursor, entries, target: summarized } : { cursor, target: summarized }; const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries. cursor ${cursor === '' ? '(none)' : cursor}.`; - // Default Agent.Text stays a short summary: dumping every entry here doubles - // the token cost of the structured value. full:true keeps the per-entry text. + // Keep both model-facing channels small by omitting entries from the default + // structured value as well as withholding per-entry Agent.Text. if (!full) { return ( @@ -66,7 +66,7 @@ export default defineTool( return ( {summary} - {value.entries.map((entry, index) => ( + {entries.map((entry, index) => ( {`[${entry.role ?? entry.kind}] ${entry.text}`} ))} diff --git a/plugin/src/skills/talk-to-grok-bot/SKILL.md b/plugin/src/skills/talk-to-grok-bot/SKILL.md index 4ad2ad8..767012a 100644 --- a/plugin/src/skills/talk-to-grok-bot/SKILL.md +++ b/plugin/src/skills/talk-to-grok-bot/SKILL.md @@ -16,7 +16,7 @@ Do not ping a bot for work you can finish yourself. Replies are asynchronous — ## How 1. `gbot_send` with `target` (name or id) and `message` (first line: who you are + what you need). -2. Later, `gbot_thread` with the same `target` (`limit` defaults to 40). It returns a short summary plus a `cursor` by default; pass `full: true` to read entry text. Bot replies are `send-message` entries; yours are `message` with `role: user`. +2. Later, `gbot_thread` with the same `target` (`limit` defaults to 40). It returns a short summary plus a `cursor` and omits entries by default; pass `full: true` to return entries and read their text. Bot replies are `send-message` entries; yours are `message` with `role: user`. List targets with `gbot bots list` / `gbot groups list` when the name is ambiguous. diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index 18ba8a0..0dfc38b 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -159,11 +159,6 @@ describe('grok-bot MCP server', () => { expect(result.isError).toBe(false); expect(result.structuredContent).toEqual({ cursor: 't3', - entries: [ - { id: 't1', kind: 'message', role: 'user', text: 'hello from the test', truncated: false, fullLength: 19, timestampMs: 1 }, - { id: 't2', kind: 'send-message', text: 'reply', truncated: false, fullLength: 5 }, - { id: 't3', kind: 'tool-call', text: '', truncated: false, fullLength: 0 }, - ], target: { id: 'grp-1', kind: 'group', name: 'Launch' }, }); expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); @@ -178,6 +173,15 @@ describe('grok-bot MCP server', () => { server: 'grok-bot', }); expect(full.isError).toBe(false); + expect(full.structuredContent).toEqual({ + cursor: 't3', + entries: [ + { id: 't1', kind: 'message', role: 'user', text: 'hello from the test', truncated: false, fullLength: 19, timestampMs: 1 }, + { id: 't2', kind: 'send-message', text: 'reply', truncated: false, fullLength: 5 }, + { id: 't3', kind: 'tool-call', text: '', truncated: false, fullLength: 0 }, + ], + target: { id: 'grp-1', kind: 'group', name: 'Launch' }, + }); expect(contentText(full.content)).toContain('[user] hello from the test'); expect(contentText(full.content)).toContain('[send-message] reply'); }); @@ -185,18 +189,13 @@ describe('grok-bot MCP server', () => { it('gbot_thread defaults the limit to 40 like the CLI and reads the other transcript shapes', async () => { const empty = await invokeMcpTool('gbot_thread', { input: { target: 'General' }, server: 'grok-bot' }); expect(calls[1]?.body).toEqual({ id: 'bot-1', limit: 40 }); - expect(empty.structuredContent).toEqual({ cursor: '', entries: [], target: { id: 'bot-1', kind: 'bot', name: 'General' } }); + expect(empty.structuredContent).toEqual({ cursor: '', target: { id: 'bot-1', kind: 'bot', name: 'General' } }); expect(contentText(empty.content)).toContain('bot General: 0 entries. cursor (none).'); const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); - expect(legacy.structuredContent).toMatchObject({ + expect(legacy.structuredContent).toEqual({ cursor: 'l4', - entries: [ - { id: 'l1', kind: 'message', text: 'direct text', truncated: false, fullLength: 11 }, - { id: 'l2', kind: 'note', text: 'part one\npart two\npart three', truncated: false, fullLength: 28 }, - { id: 'l3', kind: 'message', text: 'plain message', truncated: false, fullLength: 13 }, - { id: 'l4', kind: 'message', text: `${'x'.repeat(399)}…`, truncated: true, fullLength: 450 }, - ], + target: { id: 'bot-2', kind: 'bot', name: 'Legacy' }, }); const legacySummary = contentText(legacy.content); expect(legacySummary).toContain('bot Legacy: 4 entries. cursor l4.'); @@ -215,7 +214,7 @@ describe('grok-bot MCP server', () => { expect(fullContent.entries[3]).toEqual({ id: 'l4', kind: 'message', text: 'x'.repeat(450), truncated: false, fullLength: 450 }); expect(contentText(full.content)).toContain('x'.repeat(450)); - const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Odd' }, server: 'grok-bot' }); + const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Odd' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ cursor: 'o3', @@ -232,9 +231,11 @@ describe('grok-bot MCP server', () => { it('gbot_thread default summary stays small on a 60-entry tail while full:true reads the text', async () => { const summary = await invokeMcpTool('gbot_thread', { input: { limit: 60, target: 'Noisy' }, server: 'grok-bot' }); expect(summary.isError).toBe(false); - const structured = summary.structuredContent as { cursor: string; entries: unknown[] }; - expect(structured.entries).toHaveLength(60); + const structured = summary.structuredContent as { cursor: string; entries?: unknown[] }; + expect(structured.entries).toBeUndefined(); expect(structured.cursor).toBe('n60'); + expect(JSON.stringify(structured)).not.toContain('update 1'); + expect(JSON.stringify(structured).length).toBeLessThan(300); const summaryText = contentText(summary.content); expect(summaryText).toContain('60 entries. cursor n60.'); expect(summaryText).not.toContain('update 1'); @@ -281,7 +282,7 @@ describe('grok-bot MCP server', () => { it('gbot_thread enforces the requested count even when the gateway ignores the limit', async () => { const capped = await invokeMcpTool('gbot_thread', { - input: { limit: 3, target: 'Big' }, + input: { full: true, limit: 3, target: 'Big' }, server: 'grok-bot', }); expect(capped.isError).toBe(false); @@ -290,7 +291,7 @@ describe('grok-bot MCP server', () => { }); it('gbot_thread bounds id/kind/role metadata that would bypass the text budget', async () => { - const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); + const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Meta' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ cursor: `${'i'.repeat(200)}…`, From 3a2b405cc0c76391d04c2530812231619d4cf838 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 04:49:20 +0000 Subject: [PATCH 3/9] Keep thread result values JSON-compatible Co-authored-by: Zack Jackson --- plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 7e1a999..29a4bc0 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -52,17 +52,18 @@ export default defineTool( const entries = transcriptEntries(tail.transcript, { full, limit }); const cursor = threadCursor(entries); const summarized = summarizeTarget(tail.target); - const value = full ? { cursor, entries, target: summarized } : { cursor, target: summarized }; const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries. cursor ${cursor === '' ? '(none)' : cursor}.`; // Keep both model-facing channels small by omitting entries from the default // structured value as well as withholding per-entry Agent.Text. if (!full) { + const value = { cursor, target: summarized }; return ( {`${summary} Entry text withheld; pass full:true to read it.`} ); } + const value = { cursor, entries, target: summarized }; return ( {summary} From 308d1dce15ad19ed4d8c496a95909259e2a523be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 04:50:45 +0000 Subject: [PATCH 4/9] Preserve opaque thread cursor IDs Co-authored-by: Zack Jackson --- plugin/src/gbot.ts | 15 ++++++++------- plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 2 +- plugin/tests/route-unit/tools.test.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index 2a82484..d20abc3 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -113,14 +113,15 @@ export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; l }; /** - * Opaque client-held watermark for a thread read: the id of the last entry with - * one, or '' when the tail is empty or entries carry no ids. Slice 1 will accept - * this back as `after`; until then just hold it, never send it anywhere. + * Opaque client-held watermark for a thread read: the uncapped id of the last + * included entry with one, or '' when those entries carry no ids. Slice 1 will + * accept this back as `after`; until then just hold it, never send it anywhere. */ -export const threadCursor = (entries: readonly Pick[]): string => { - for (let index = entries.length - 1; index >= 0; index -= 1) { - const id = entries[index]?.id; - if (typeof id === 'string' && id !== '') return id; +export const threadCursor = (transcript: unknown, entryCount: number): string => { + const rows = unwrapEntries(transcript).slice(0, entryCount); + for (let index = rows.length - 1; index >= 0; index -= 1) { + const fields = entryFields.safeParse(rows[index]); + if (fields.success && fields.data.id !== '') return fields.data.id; } return ''; }; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 29a4bc0..31a046e 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -50,7 +50,7 @@ export default defineTool( async ({ limit, target, full }) => { const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit)); const entries = transcriptEntries(tail.transcript, { full, limit }); - const cursor = threadCursor(entries); + const cursor = threadCursor(tail.transcript, entries.length); const summarized = summarizeTarget(tail.target); const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries. cursor ${cursor === '' ? '(none)' : cursor}.`; // Keep both model-facing channels small by omitting entries from the default diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index 0dfc38b..f1bfe90 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -291,10 +291,17 @@ describe('grok-bot MCP server', () => { }); it('gbot_thread bounds id/kind/role metadata that would bypass the text budget', async () => { + const summary = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); + expect(summary.isError).toBe(false); + expect(summary.structuredContent).toEqual({ + cursor: 'i'.repeat(300), + target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, + }); + const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Meta' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ - cursor: `${'i'.repeat(200)}…`, + cursor: 'i'.repeat(300), entries: [ { id: `${'i'.repeat(200)}…`, From 99beb1340f3211c1b685e59a3494f262c79226ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 04:54:59 +0000 Subject: [PATCH 5/9] Keep thread summaries bounded Co-authored-by: Zack Jackson --- plugin/src/gbot.ts | 3 ++- plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 6 ++--- plugin/tests/route-unit/tools.test.ts | 26 +++++++++++++------ 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index d20abc3..6c02785 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -68,6 +68,7 @@ const entryFields = z.object({ role: z.string().optional(), timestampMs: z.number().optional(), }); +const cursorFields = entryFields.pick({ id: true }); const capMeta = (value: string): string => (value.length > ENTRY_META_MAX ? `${value.slice(0, ENTRY_META_MAX)}…` : value); @@ -120,7 +121,7 @@ export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; l export const threadCursor = (transcript: unknown, entryCount: number): string => { const rows = unwrapEntries(transcript).slice(0, entryCount); for (let index = rows.length - 1; index >= 0; index -= 1) { - const fields = entryFields.safeParse(rows[index]); + const fields = cursorFields.safeParse(rows[index]); if (fields.success && fields.data.id !== '') return fields.data.id; } return ''; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 31a046e..3c9f5db 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -29,7 +29,7 @@ export default defineTool( full: { default: false, description: - 'Return complete entry text up to bounded budgets (20k chars per entry, 200k total) instead of the 400-character preview. Every entry still reports truncated/fullLength; read the remainder with `gbot thread --full` / `--json` on the machine.', + 'Return entries with text up to bounded budgets (20k chars per entry, 200k total). Every entry reports truncated/fullLength; read any remainder with `gbot thread --full` / `--json` on the machine.', type: 'boolean', }, target: { description: 'Bot or group name or id, for example "General".', type: 'string' }, @@ -52,14 +52,14 @@ export default defineTool( const entries = transcriptEntries(tail.transcript, { full, limit }); const cursor = threadCursor(tail.transcript, entries.length); const summarized = summarizeTarget(tail.target); - const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries. cursor ${cursor === '' ? '(none)' : cursor}.`; + const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries.`; // Keep both model-facing channels small by omitting entries from the default // structured value as well as withholding per-entry Agent.Text. if (!full) { const value = { cursor, target: summarized }; return ( - {`${summary} Entry text withheld; pass full:true to read it.`} + {`${summary} Cursor returned separately; entry text withheld; pass full:true to read it.`} ); } diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index f1bfe90..8e0f03a 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -65,7 +65,10 @@ const transcripts: Record = { entries: Array.from({ length: 11 }, (_, index) => ({ id: `b${index}`, kind: 'note', text: 'q'.repeat(20000) })), }, 'bot-7': { - entries: [{ id: 'i'.repeat(300), kind: 'k'.repeat(300), role: 'R'.repeat(5000), text: 'hi' }], + entries: [ + { id: 'i'.repeat(300), kind: 'k'.repeat(300), role: 'R'.repeat(5000), text: 'hi' }, + { id: 'last-valid-id', kind: 7 }, + ], }, }; const responses: Record) => [number, unknown]> = { @@ -163,7 +166,8 @@ describe('grok-bot MCP server', () => { }); expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); const summary = contentText(result.content); - expect(summary).toContain('group Launch: 3 entries. cursor t3.'); + expect(summary).toContain('group Launch: 3 entries.'); + expect(summary).not.toContain('cursor t3'); expect(summary).toContain('pass full:true'); expect(summary).not.toContain('hello from the test'); expect(summary).not.toContain('reply'); @@ -190,7 +194,7 @@ describe('grok-bot MCP server', () => { const empty = await invokeMcpTool('gbot_thread', { input: { target: 'General' }, server: 'grok-bot' }); expect(calls[1]?.body).toEqual({ id: 'bot-1', limit: 40 }); expect(empty.structuredContent).toEqual({ cursor: '', target: { id: 'bot-1', kind: 'bot', name: 'General' } }); - expect(contentText(empty.content)).toContain('bot General: 0 entries. cursor (none).'); + expect(contentText(empty.content)).toContain('bot General: 0 entries.'); const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); expect(legacy.structuredContent).toEqual({ @@ -198,7 +202,8 @@ describe('grok-bot MCP server', () => { target: { id: 'bot-2', kind: 'bot', name: 'Legacy' }, }); const legacySummary = contentText(legacy.content); - expect(legacySummary).toContain('bot Legacy: 4 entries. cursor l4.'); + expect(legacySummary).toContain('bot Legacy: 4 entries.'); + expect(legacySummary).not.toContain('cursor l4'); expect(legacySummary).not.toContain('direct text'); expect(legacySummary).not.toContain('…'); expect(legacySummary).not.toContain('x'.repeat(450)); @@ -225,7 +230,7 @@ describe('grok-bot MCP server', () => { ], target: { id: 'bot-4', kind: 'bot', name: 'Odd' }, }); - expect(contentText(odd.content)).toContain('bot Odd: 3 entries. cursor o3.'); + expect(contentText(odd.content)).toContain('bot Odd: 3 entries.'); }); it('gbot_thread default summary stays small on a 60-entry tail while full:true reads the text', async () => { @@ -237,7 +242,8 @@ describe('grok-bot MCP server', () => { expect(JSON.stringify(structured)).not.toContain('update 1'); expect(JSON.stringify(structured).length).toBeLessThan(300); const summaryText = contentText(summary.content); - expect(summaryText).toContain('60 entries. cursor n60.'); + expect(summaryText).toContain('60 entries.'); + expect(summaryText).not.toContain('cursor n60'); expect(summaryText).not.toContain('update 1'); const full = await invokeMcpTool('gbot_thread', { @@ -294,14 +300,17 @@ describe('grok-bot MCP server', () => { const summary = await invokeMcpTool('gbot_thread', { input: { target: 'Meta' }, server: 'grok-bot' }); expect(summary.isError).toBe(false); expect(summary.structuredContent).toEqual({ - cursor: 'i'.repeat(300), + cursor: 'last-valid-id', target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, }); + const summaryText = contentText(summary.content); + expect(summaryText).not.toContain('i'.repeat(300)); + expect(summaryText.length).toBeLessThan(300); const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Meta' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ - cursor: 'i'.repeat(300), + cursor: 'last-valid-id', entries: [ { id: `${'i'.repeat(200)}…`, @@ -311,6 +320,7 @@ describe('grok-bot MCP server', () => { truncated: false, fullLength: 2, }, + { id: '', kind: 'unknown', text: '', truncated: false, fullLength: 0 }, ], target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, }); From 5fea5560e7036fe24b7a5db3068c0e6442ea9bea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 05:02:09 +0000 Subject: [PATCH 6/9] Add client-side thread watermark filtering Co-authored-by: Zack Jackson --- src/cli.js | 23 ++++++++++++++++------ src/transcript.js | 28 +++++++++++++++++++++++++++ test/history.test.js | 31 +++++++++++++++++++++++++++++ test/transcript.test.js | 43 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/cli.js b/src/cli.js index d2eb07d..a610822 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,7 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; -import { entryText, transcriptEntries } from "./transcript.js"; +import { entryText, transcriptDelta, transcriptEntries } from "./transcript.js"; import { historyPath, readHistory, saveHistory } from "./history.js"; import { redactSecrets } from "./url-policy.js"; import { codexStatus, listCodexThreads, sendToCodexThread } from "./codex-bridge.js"; @@ -58,7 +58,7 @@ function usage() { " groups set --member ID [--member ...]", " groups delete ", " send ", - " thread [--limit N] [--root MESSAGE_ID] [--full]", + " thread [--limit N] [--after ENTRY_ID] [--root MESSAGE_ID] [--full]", " chat alias for thread", " history [bot-or-group] [--search TEXT] [--limit N] (offline)", " history --path print the local JSONL file path", @@ -561,16 +561,27 @@ async function main(argv) { if (cmd === "thread" || cmd === "chat") { const ref = sub; - if (!ref) throw new StoreError("gbot thread [--limit N] [--root MESSAGE_ID] [--full]"); + if (!ref) throw new StoreError("gbot thread [--limit N] [--after ENTRY_ID] [--root MESSAGE_ID] [--full]"); const full = hasFlag(rest, "--full"); const limitRaw = takeFlag(rest, "--limit"); const rootId = takeFlag(rest, "--root"); + const after = takeFlag(rest, "--after"); const limit = limitRaw ? Number(limitRaw) : 40; if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new StoreError("--limit must be an integer 1-200"); + if (after !== undefined && (after.length === 0 || after.length > 1024)) throw new StoreError("--after must be 1-1024 characters"); + if (after !== undefined && rootId !== undefined) throw new StoreError("--after cannot be combined with --root"); const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit); - saveHistory(out, { dir: historyDir, disabled: noHistory, event: cmd, rootId }); - if (json) print(out); - else print(formatTranscript(out, { full })); + let selected = out; + if (after !== undefined) { + const delta = transcriptDelta(out.transcript, { after, limit }); + selected = { ...out, transcript: { entries: delta.entries }, cursor: delta.cursor, entryCount: delta.entryCount, gapReset: delta.gapReset }; + } + saveHistory(selected, { dir: historyDir, disabled: noHistory, event: cmd, rootId }); + if (json) print(selected); + else { + const text = formatTranscript(selected, { full }); + print(after === undefined ? text : text + "\n\ncursor: " + selected.cursor + (selected.gapReset ? " (gap reset)" : "")); + } return; } diff --git a/src/transcript.js b/src/transcript.js index 2eb5ab1..b37929e 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -48,3 +48,31 @@ export function transcriptEntries(payload) { const entries = payload.entries || payload.messages || payload.items; return Array.isArray(entries) ? entries : []; } + +export function sourceEntryId(entry) { + if (!entry || typeof entry !== "object") return ""; + if (typeof entry.id === "string" && entry.id) return entry.id; + return typeof entry.messageId === "string" ? entry.messageId : ""; +} + +function lastSourceId(entries, fallback = "") { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const id = sourceEntryId(entries[index]); + if (id) return id; + } + return fallback; +} + +export function transcriptDelta(payload, { after, limit = 40 } = {}) { + const bounded = Math.min(Math.max(Math.trunc(limit) || 40, 1), 200); + const page = transcriptEntries(payload).slice(-bounded); + if (after === undefined) { + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: false }; + } + const afterIndex = page.findIndex((entry) => sourceEntryId(entry) === after); + if (afterIndex === -1) { + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: true }; + } + const entries = page.slice(afterIndex + 1); + return { cursor: lastSourceId(entries, after), entries, entryCount: entries.length, gapReset: false }; +} diff --git a/test/history.test.js b/test/history.test.js index 616272e..b1c5154 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -109,6 +109,37 @@ test("supports all transcript envelopes and text fields already displayed by the assert.deepEqual(f.rows().map((r) => r.text), ["message text", "prompt text", "content text"]); }); +test("thread --after returns exclusive deltas, no-op receipts, and bounded gap resets", async (t) => { + const f = await fixture(t); + const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); + f.state.payload = { entries }; + + const newer = JSON.parse((await f.run(["thread", "Researcher", "--limit", "45", "--after", "m43", "--json"])).stdout); + assert.deepEqual(f.calls.at(-1), { method: "/api/getAgentTranscriptTail", body: { id: target.id, limit: 45 } }); + assert.deepEqual(newer.transcript.entries, entries.slice(43)); + assert.deepEqual({ cursor: newer.cursor, entryCount: newer.entryCount, gapReset: newer.gapReset }, { + cursor: "m45", + entryCount: 2, + gapReset: false, + }); + + const unchanged = JSON.parse((await f.run(["thread", "Researcher", "--limit", "45", "--after", "m45", "--json"])).stdout); + assert.deepEqual(unchanged.transcript.entries, []); + assert.deepEqual({ cursor: unchanged.cursor, entryCount: unchanged.entryCount, gapReset: unchanged.gapReset }, { + cursor: "m45", + entryCount: 0, + gapReset: false, + }); + + const reset = JSON.parse((await f.run(["thread", "Researcher", "--limit", "40", "--after", "bogus", "--json"])).stdout); + assert.deepEqual(reset.transcript.entries, entries.slice(-40)); + assert.deepEqual({ cursor: reset.cursor, entryCount: reset.entryCount, gapReset: reset.gapReset }, { + cursor: "m45", + entryCount: 40, + gapReset: true, + }); +}); + test("recording opt-outs do not create storage or disable access to existing history", async (t) => { const f = await fixture(t); await f.run(["--no-history", "send", "Researcher", "private prompt"]); diff --git a/test/transcript.test.js b/test/transcript.test.js index e8a0958..d37feb9 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { entryText, transcriptEntries } from "../src/transcript.js"; +import { entryText, sourceEntryId, transcriptDelta, transcriptEntries } from "../src/transcript.js"; test("user messages read content, bot replies read message.content", () => { assert.equal(entryText({ kind: "message", role: "user", content: "hello" }), "hello"); @@ -34,3 +34,44 @@ test("entryText never throws and normalizes malformed content to safe strings", assert.equal(entryText({ text: "a\ud800b" }), "a\ufffdb"); assert.equal(entryText(null), ""); }); + +test("transcript delta uses opaque source ids and the true bounded tail", () => { + const longId = "i".repeat(300); + const entries = [ + { id: "old", text: "old" }, + { id: longId, kind: 7, text: "middle" }, + { id: "latest", text: "latest" }, + ]; + assert.equal(sourceEntryId(entries[1]), longId); + assert.deepEqual(transcriptDelta({ entries }, { limit: 2 }), { + cursor: "latest", + entries: entries.slice(1), + entryCount: 2, + gapReset: false, + }); +}); + +test("transcript delta filters exclusively after a known cursor and keeps no-op cursor", () => { + const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); + assert.deepEqual(transcriptDelta({ entries }, { after: "m43", limit: 45 }), { + cursor: "m45", + entries: entries.slice(43), + entryCount: 2, + gapReset: false, + }); + assert.deepEqual(transcriptDelta({ entries }, { after: "m45", limit: 45 }), { + cursor: "m45", + entries: [], + entryCount: 0, + gapReset: false, + }); +}); + +test("transcript delta resets an unknown cursor with one bounded snapshot", () => { + const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); + const delta = transcriptDelta({ entries }, { after: "bogus", limit: 40 }); + assert.equal(delta.gapReset, true); + assert.equal(delta.entryCount, 40); + assert.deepEqual(delta.entries, entries.slice(-40)); + assert.equal(delta.cursor, "m45"); +}); From 7dc9195bd330a50fcd3df64336f77736392c9631 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 05:04:56 +0000 Subject: [PATCH 7/9] Return bounded thread receipts and artifacts Co-authored-by: Zack Jackson --- plugin/src/gbot.ts | 102 ++++++++++-- plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 60 +++++-- plugin/tests/route-unit/tools.test.ts | 152 +++++++++++++++--- src/transcript.js | 2 +- 4 files changed, 262 insertions(+), 54 deletions(-) diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index 6c02785..06bfa16 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -1,10 +1,15 @@ +import { createHash } from 'node:crypto'; +import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + import { z } from 'zod'; import { connectGateway, getTranscriptTail, sendPrompt } from 'grok-bot-cli/src/gateway.js'; -import { entryText, transcriptEntries as unwrapEntries } from 'grok-bot-cli/src/transcript.js'; +import { historyPath } from 'grok-bot-cli/src/history.js'; +import { entryText, transcriptDelta, transcriptEntries as unwrapEntries } from 'grok-bot-cli/src/transcript.js'; import { redactSecrets } from 'grok-bot-cli/src/url-policy.js'; -export { connectGateway, getTranscriptTail, sendPrompt }; +export { connectGateway, getTranscriptTail, sendPrompt, transcriptDelta }; // The same pass `fail()` in src/cli.js applies before printing: MCP hosts show // the error text, and a fetch or proxy failure can echo a credential. @@ -56,6 +61,11 @@ export const ENTRY_FULL_MAX = 20000; export const TRANSCRIPT_TOTAL_MAX = 200000; // Metadata fields are capped too: an uncapped id/kind/role would bypass the total budget. export const ENTRY_META_MAX = 200; +export const RECEIPT_CURSOR_MAX = 1024; +export const RECEIPT_PATH_MAX = 1024; +export const RECEIPT_SUMMARY_MAX = 256; +export const RECEIPT_MAX_BYTES = 4096; +export const THREAD_ARTIFACT_MAX_BYTES = 256 * 1024; export const truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => { if (text.length <= max) return text; @@ -68,7 +78,6 @@ const entryFields = z.object({ role: z.string().optional(), timestampMs: z.number().optional(), }); -const cursorFields = entryFields.pick({ id: true }); const capMeta = (value: string): string => (value.length > ENTRY_META_MAX ? `${value.slice(0, ENTRY_META_MAX)}…` : value); @@ -113,16 +122,81 @@ export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; l }); }; -/** - * Opaque client-held watermark for a thread read: the uncapped id of the last - * included entry with one, or '' when those entries carry no ids. Slice 1 will - * accept this back as `after`; until then just hold it, never send it anywhere. - */ -export const threadCursor = (transcript: unknown, entryCount: number): string => { - const rows = unwrapEntries(transcript).slice(0, entryCount); - for (let index = rows.length - 1; index >= 0; index -= 1) { - const fields = cursorFields.safeParse(rows[index]); - if (fields.success && fields.data.id !== '') return fields.data.id; +const artifactEnabled = (): boolean => /^(on|true|1)$/iu.test(process.env.GROK_BOT_HISTORY ?? ''); + +const boundedMarkdown = (markdown: string): string => { + const bytes = Buffer.from(markdown); + if (bytes.length <= THREAD_ARTIFACT_MAX_BYTES) return markdown; + const marker = Buffer.from('\n\n[artifact truncated]\n'); + const prefix = bytes.subarray(0, THREAD_ARTIFACT_MAX_BYTES - marker.length).toString('utf8').replace(/\uFFFD$/u, ''); + return `${prefix}${marker.toString('utf8')}`; +}; + +const renderThreadArtifact = ( + target: z.infer, + entries: readonly Entry[], + cursor: string, +): string => { + const title = `${target.kind} ${capMeta(target.name)} (${capMeta(target.id)})`; + const sections = entries.map((entry) => { + const label = entry.role ?? entry.kind; + return `## ${label}${entry.id ? ` ${entry.id}` : ''}\n\n${entry.text}`; + }); + return boundedMarkdown(`# ${title}\n\nCursor: ${cursor}\n\n${sections.join('\n\n')}\n`); +}; + +export const saveThreadArtifact = ( + target: z.infer, + entries: readonly Entry[], + cursor: string, + update: boolean, +): string | undefined => { + if (!artifactEnabled()) return undefined; + try { + const root = dirname(historyPath()); + const filename = `${createHash('sha256').update(target.id).digest('hex').slice(0, 24)}.md`; + const path = join(root, 'thread-artifacts', filename); + if (Buffer.byteLength(path, 'utf8') > RECEIPT_PATH_MAX) return undefined; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + if (!update && existsSync(path)) { + const stat = lstatSync(path); + return stat.isFile() && !stat.isSymbolicLink() ? path : undefined; + } + const fd = openSync(path, constants.O_CREAT | constants.O_TRUNC | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + try { + writeFileSync(fd, renderThreadArtifact(target, entries, cursor), 'utf8'); + } finally { + closeSync(fd); + } + return path; + } catch { + return undefined; + } +}; + +export const threadSummary = ( + entryCount: number, + after: string | undefined, + gapReset: boolean, + path: string | undefined, +): string => { + if (after !== undefined && entryCount === 0 && !gapReset) return '0 new'; + const count = after !== undefined && !gapReset ? `${entryCount} new` : `${entryCount} entries`; + return `${count}${gapReset ? '; gap reset' : ''}; ${path ? 'document updated' : 'artifact unavailable'}`; +}; + +export const assertReceiptBudget = (receipt: { + cursor: string; + path?: string; + summary: string; + [key: string]: unknown; +}): void => { + if (receipt.cursor.length > RECEIPT_CURSOR_MAX) throw new Error('Thread cursor exceeds 1024 characters'); + if (receipt.summary.length > RECEIPT_SUMMARY_MAX) throw new Error('Thread summary exceeds 256 characters'); + if (receipt.path !== undefined && Buffer.byteLength(receipt.path, 'utf8') > RECEIPT_PATH_MAX) { + throw new Error('Thread artifact path exceeds 1024 bytes'); + } + if (Buffer.byteLength(JSON.stringify(receipt), 'utf8') > RECEIPT_MAX_BYTES) { + throw new Error('Thread receipt exceeds 4096 bytes'); } - return ''; }; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 3c9f5db..37bb2f2 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -3,12 +3,18 @@ import { defineTool } from 'agent-bundle/routes'; import { z } from 'zod'; import { + assertReceiptBudget, connectGateway, entrySchema, getTranscriptTail, + RECEIPT_CURSOR_MAX, + RECEIPT_PATH_MAX, + RECEIPT_SUMMARY_MAX, + saveThreadArtifact, summarizeTarget, targetSchema, - threadCursor, + threadSummary, + transcriptDelta, transcriptEntries, withRedactedErrors, } from '../../../gbot.js'; @@ -17,10 +23,14 @@ export default defineTool( { annotations: { readOnlyHint: true }, description: - 'Read the most recent messages in a Grok Bot bot or group thread, like `gbot thread`. By default returns a short summary plus a cursor and withholds entry text; pass full:true to read the text. Use it to collect the reply to a gbot_send.', + 'Read a bounded Grok Bot thread tail. Returns a small receipt by default; pass the last cursor as after for an exclusive client-side delta, or full:true to include bounded entry text.', inputJsonSchema: { additionalProperties: false, properties: { + after: { + description: 'Opaque cursor from the previous call. Returns entries strictly after it; an unknown cursor resets with a bounded snapshot.', + type: 'string', + }, limit: { default: 40, description: 'How many trailing entries to inspect (1-200). Entries are returned only with full:true.', @@ -40,36 +50,54 @@ export default defineTool( inputSchema: z.object({ // ponytail: the route inputJsonSchema type cannot express minimum/maximum, so the // 1-200 bound lives here in zod (and in the CLI/gateway); widen the route type to align them. + after: z.string().min(1).max(RECEIPT_CURSOR_MAX).optional(), limit: z.number().int().min(1).max(200).default(40), full: z.boolean().default(false), target: z.string().min(1), }), - resultSchema: z.object({ cursor: z.string(), entries: z.array(entrySchema).optional(), target: targetSchema }), + resultSchema: z.object({ + cursor: z.string().max(RECEIPT_CURSOR_MAX), + entries: z.array(entrySchema).optional(), + entryCount: z.number().int().min(0).max(200), + gapReset: z.boolean(), + path: z.string().max(RECEIPT_PATH_MAX).optional(), + summary: z.string().max(RECEIPT_SUMMARY_MAX), + target: targetSchema, + }), title: 'Read a Grok Bot thread', }, - async ({ limit, target, full }) => { + async ({ after, limit, target, full }) => { const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit)); - const entries = transcriptEntries(tail.transcript, { full, limit }); - const cursor = threadCursor(tail.transcript, entries.length); + const delta = transcriptDelta(tail.transcript, { after, limit }); + const entries = transcriptEntries(delta.entries, { full: true }); const summarized = summarizeTarget(tail.target); - const summary = `${summarized.kind} ${summarized.name}: ${entries.length} entries.`; - // Keep both model-facing channels small by omitting entries from the default - // structured value as well as withholding per-entry Agent.Text. + const path = saveThreadArtifact( + summarized, + entries, + delta.cursor, + after === undefined || delta.entryCount > 0 || delta.gapReset, + ); + const summary = threadSummary(delta.entryCount, after, delta.gapReset, path); + const receipt = { + cursor: delta.cursor, + entryCount: delta.entryCount, + gapReset: delta.gapReset, + ...(path === undefined ? {} : { path }), + summary, + target: summarized, + }; + assertReceiptBudget(receipt); if (!full) { - const value = { cursor, target: summarized }; return ( - - {`${summary} Cursor returned separately; entry text withheld; pass full:true to read it.`} + + {summary} ); } - const value = { cursor, entries, target: summarized }; + const value = { ...receipt, entries }; return ( {summary} - {entries.map((entry, index) => ( - {`[${entry.role ?? entry.kind}] ${entry.text}`} - ))} ); }, diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index 8e0f03a..3c67fc1 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -1,5 +1,8 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { createServer, type IncomingMessage, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@rstest/core'; import { invokeMcpTool, listMcpSurface } from 'agent-bundle/test'; @@ -85,7 +88,13 @@ const responses: Record) => [number, unkn const calls: GatewayCall[] = []; let server: Server; const savedEnv: Record = {}; -const envKeys = ['GROK_BOT_GATEWAY_URL', 'GROK_BOT_GATEWAY_TOKEN', 'GROK_BOT_ALLOW_LOCAL_GATEWAY']; +const envKeys = [ + 'GROK_BOT_GATEWAY_URL', + 'GROK_BOT_GATEWAY_TOKEN', + 'GROK_BOT_ALLOW_LOCAL_GATEWAY', + 'GROK_BOT_HISTORY', + 'GROK_BOT_HISTORY_DIR', +]; const contentText = (content: readonly { readonly text?: string }[]): string => content.map((block) => block.text ?? '').join('\n'); @@ -114,6 +123,8 @@ beforeAll(async () => { process.env.GROK_BOT_GATEWAY_URL = `http://127.0.0.1:${port}`; process.env.GROK_BOT_GATEWAY_TOKEN = 'test-token'; process.env.GROK_BOT_ALLOW_LOCAL_GATEWAY = '1'; + process.env.GROK_BOT_HISTORY = 'off'; + delete process.env.GROK_BOT_HISTORY_DIR; }); afterAll(async () => { @@ -162,13 +173,14 @@ describe('grok-bot MCP server', () => { expect(result.isError).toBe(false); expect(result.structuredContent).toEqual({ cursor: 't3', + entryCount: 3, + gapReset: false, + summary: '3 entries; artifact unavailable', target: { id: 'grp-1', kind: 'group', name: 'Launch' }, }); expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); const summary = contentText(result.content); - expect(summary).toContain('group Launch: 3 entries.'); - expect(summary).not.toContain('cursor t3'); - expect(summary).toContain('pass full:true'); + expect(summary).toBe('3 entries; artifact unavailable'); expect(summary).not.toContain('hello from the test'); expect(summary).not.toContain('reply'); @@ -179,31 +191,41 @@ describe('grok-bot MCP server', () => { expect(full.isError).toBe(false); expect(full.structuredContent).toEqual({ cursor: 't3', + entryCount: 3, entries: [ { id: 't1', kind: 'message', role: 'user', text: 'hello from the test', truncated: false, fullLength: 19, timestampMs: 1 }, { id: 't2', kind: 'send-message', text: 'reply', truncated: false, fullLength: 5 }, { id: 't3', kind: 'tool-call', text: '', truncated: false, fullLength: 0 }, ], + gapReset: false, + summary: '3 entries; artifact unavailable', target: { id: 'grp-1', kind: 'group', name: 'Launch' }, }); - expect(contentText(full.content)).toContain('[user] hello from the test'); - expect(contentText(full.content)).toContain('[send-message] reply'); + expect(contentText(full.content)).toBe('3 entries; artifact unavailable'); }); it('gbot_thread defaults the limit to 40 like the CLI and reads the other transcript shapes', async () => { const empty = await invokeMcpTool('gbot_thread', { input: { target: 'General' }, server: 'grok-bot' }); expect(calls[1]?.body).toEqual({ id: 'bot-1', limit: 40 }); - expect(empty.structuredContent).toEqual({ cursor: '', target: { id: 'bot-1', kind: 'bot', name: 'General' } }); - expect(contentText(empty.content)).toContain('bot General: 0 entries.'); + expect(empty.structuredContent).toEqual({ + cursor: '', + entryCount: 0, + gapReset: false, + summary: '0 entries; artifact unavailable', + target: { id: 'bot-1', kind: 'bot', name: 'General' }, + }); + expect(contentText(empty.content)).toBe('0 entries; artifact unavailable'); const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); expect(legacy.structuredContent).toEqual({ cursor: 'l4', + entryCount: 4, + gapReset: false, + summary: '4 entries; artifact unavailable', target: { id: 'bot-2', kind: 'bot', name: 'Legacy' }, }); const legacySummary = contentText(legacy.content); - expect(legacySummary).toContain('bot Legacy: 4 entries.'); - expect(legacySummary).not.toContain('cursor l4'); + expect(legacySummary).toBe('4 entries; artifact unavailable'); expect(legacySummary).not.toContain('direct text'); expect(legacySummary).not.toContain('…'); expect(legacySummary).not.toContain('x'.repeat(450)); @@ -217,45 +239,122 @@ describe('grok-bot MCP server', () => { expect(full.isError).toBe(false); const fullContent = full.structuredContent as { entries: { id: string; text: string; truncated: boolean; fullLength: number }[] }; expect(fullContent.entries[3]).toEqual({ id: 'l4', kind: 'message', text: 'x'.repeat(450), truncated: false, fullLength: 450 }); - expect(contentText(full.content)).toContain('x'.repeat(450)); + expect(contentText(full.content)).not.toContain('x'.repeat(450)); const odd = await invokeMcpTool('gbot_thread', { input: { full: true, target: 'Odd' }, server: 'grok-bot' }); expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ cursor: 'o3', + entryCount: 3, entries: [ { id: 'o1', kind: 'note', text: '5', truncated: false, fullLength: 1 }, { id: 'o2', kind: 'mystery', text: '', truncated: false, fullLength: 0 }, { id: 'o3', kind: 'note', text: 'a�b', truncated: false, fullLength: 3 }, ], + gapReset: false, + summary: '3 entries; artifact unavailable', target: { id: 'bot-4', kind: 'bot', name: 'Odd' }, }); - expect(contentText(odd.content)).toContain('bot Odd: 3 entries.'); + expect(contentText(odd.content)).toBe('3 entries; artifact unavailable'); }); - it('gbot_thread default summary stays small on a 60-entry tail while full:true reads the text', async () => { + it('gbot_thread returns tiny no-op receipts and exclusive deltas without sending after upstream', async () => { const summary = await invokeMcpTool('gbot_thread', { input: { limit: 60, target: 'Noisy' }, server: 'grok-bot' }); expect(summary.isError).toBe(false); - const structured = summary.structuredContent as { cursor: string; entries?: unknown[] }; + const structured = summary.structuredContent as { cursor: string; entries?: unknown[]; entryCount: number }; expect(structured.entries).toBeUndefined(); expect(structured.cursor).toBe('n60'); + expect(structured.entryCount).toBe(60); expect(JSON.stringify(structured)).not.toContain('update 1'); - expect(JSON.stringify(structured).length).toBeLessThan(300); + expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThan(4096); const summaryText = contentText(summary.content); - expect(summaryText).toContain('60 entries.'); - expect(summaryText).not.toContain('cursor n60'); + expect(summaryText).toBe('60 entries; artifact unavailable'); expect(summaryText).not.toContain('update 1'); + const newer = await invokeMcpTool('gbot_thread', { + input: { after: 'n58', full: true, limit: 60, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(newer.isError).toBe(false); + expect(newer.structuredContent).toMatchObject({ + cursor: 'n60', + entryCount: 2, + gapReset: false, + summary: '2 new; artifact unavailable', + }); + expect((newer.structuredContent as { entries: { id: string }[] }).entries.map((entry) => entry.id)).toEqual(['n59', 'n60']); + + const unchanged = await invokeMcpTool('gbot_thread', { + input: { after: 'n60', limit: 60, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(unchanged.isError).toBe(false); + expect(unchanged.structuredContent).toMatchObject({ + cursor: 'n60', + entryCount: 0, + gapReset: false, + summary: '0 new', + }); + expect((unchanged.structuredContent as { entries?: unknown[] }).entries).toBeUndefined(); + const full = await invokeMcpTool('gbot_thread', { input: { full: true, limit: 60, target: 'Noisy' }, server: 'grok-bot', }); expect(full.isError).toBe(false); - const fullText = contentText(full.content); + const fullText = JSON.stringify(full.structuredContent); expect(fullText).toContain('update 1'); - // No-op poll token cost ≪ full tail. - expect(summaryText.length).toBeLessThan(300); - expect(fullText.length).toBeGreaterThan(10000); + expect(contentText(full.content)).not.toContain('update 1'); + expect(Buffer.byteLength(JSON.stringify(unchanged.structuredContent))).toBeLessThan(Buffer.byteLength(fullText) / 10); + expect(calls.filter((call) => call.method === 'getAgentTranscriptTail').every((call) => !('after' in call.body))).toBe(true); + }); + + it('gbot_thread resets unknown cursors with one bounded snapshot', async () => { + const reset = await invokeMcpTool('gbot_thread', { + input: { after: 'bogus', full: true, limit: 40, target: 'Noisy' }, + server: 'grok-bot', + }); + expect(reset.isError).toBe(false); + expect(reset.structuredContent).toMatchObject({ + cursor: 'n60', + entryCount: 40, + gapReset: true, + summary: '40 entries; gap reset; artifact unavailable', + }); + const entries = (reset.structuredContent as { entries: { id: string }[] }).entries; + expect(entries).toHaveLength(40); + expect(entries[0]?.id).toBe('n21'); + expect(entries[39]?.id).toBe('n60'); + }); + + it('gbot_thread writes a bounded artifact only under the opt-in history root', async () => { + const dir = mkdtempSync(join(tmpdir(), 'gbot-thread-artifact-')); + process.env.GROK_BOT_HISTORY = 'on'; + process.env.GROK_BOT_HISTORY_DIR = dir; + try { + const result = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); + expect(result.isError).toBe(false); + const receipt = result.structuredContent as { path?: string; summary: string }; + expect(receipt.path?.startsWith(join(dir, 'thread-artifacts'))).toBe(true); + expect(receipt.path?.length).toBeLessThanOrEqual(1024); + expect(receipt.summary).toBe('4 entries; document updated'); + expect(existsSync(receipt.path ?? '')).toBe(true); + const markdown = readFileSync(receipt.path ?? '', 'utf8'); + expect(markdown).toContain('direct text'); + expect(markdown).toContain('x'.repeat(450)); + expect(statSync(receipt.path ?? '').size).toBeLessThanOrEqual(256 * 1024); + + const unchanged = await invokeMcpTool('gbot_thread', { + input: { after: 'l4', target: 'Legacy' }, + server: 'grok-bot', + }); + expect(unchanged.structuredContent).toMatchObject({ entryCount: 0, path: receipt.path, summary: '0 new' }); + expect(readFileSync(receipt.path ?? '', 'utf8')).toBe(markdown); + } finally { + process.env.GROK_BOT_HISTORY = 'off'; + delete process.env.GROK_BOT_HISTORY_DIR; + rmSync(dir, { force: true, recursive: true }); + } }); it('gbot_send stays unknown when the gateway confirms no receipt', async () => { @@ -292,8 +391,9 @@ describe('grok-bot MCP server', () => { server: 'grok-bot', }); expect(capped.isError).toBe(false); - const entries = (capped.structuredContent as { entries: unknown[] }).entries; - expect(entries.length).toBe(3); + const cappedContent = capped.structuredContent as { cursor: string; entries: { id: string }[] }; + expect(cappedContent.entries.map((entry) => entry.id)).toEqual(['b8', 'b9', 'b10']); + expect(cappedContent.cursor).toBe('b10'); }); it('gbot_thread bounds id/kind/role metadata that would bypass the text budget', async () => { @@ -301,6 +401,9 @@ describe('grok-bot MCP server', () => { expect(summary.isError).toBe(false); expect(summary.structuredContent).toEqual({ cursor: 'last-valid-id', + entryCount: 2, + gapReset: false, + summary: '2 entries; artifact unavailable', target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, }); const summaryText = contentText(summary.content); @@ -311,6 +414,7 @@ describe('grok-bot MCP server', () => { expect(odd.isError).toBe(false); expect(odd.structuredContent).toEqual({ cursor: 'last-valid-id', + entryCount: 2, entries: [ { id: `${'i'.repeat(200)}…`, @@ -322,6 +426,8 @@ describe('grok-bot MCP server', () => { }, { id: '', kind: 'unknown', text: '', truncated: false, fullLength: 0 }, ], + gapReset: false, + summary: '2 entries; artifact unavailable', target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, }); }); diff --git a/src/transcript.js b/src/transcript.js index b37929e..7442a21 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -63,7 +63,7 @@ function lastSourceId(entries, fallback = "") { return fallback; } -export function transcriptDelta(payload, { after, limit = 40 } = {}) { +export function transcriptDelta(payload, { after, limit = 40 } = /** @type {{ after?: string, limit?: number }} */ ({})) { const bounded = Math.min(Math.max(Math.trunc(limit) || 40, 1), 200); const page = transcriptEntries(payload).slice(-bounded); if (after === undefined) { From 00205a63b89e14dff0195b9b9e8cda4bb8ca623f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 05:06:07 +0000 Subject: [PATCH 8/9] Document thread receipts and watermark polling Co-authored-by: Zack Jackson --- .changeset/thread-after-filter.md | 5 +++++ README.md | 15 +++++++++++++++ plugin/src/skills/talk-to-grok-bot/SKILL.md | 6 +++++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/thread-after-filter.md diff --git a/.changeset/thread-after-filter.md b/.changeset/thread-after-filter.md new file mode 100644 index 0000000..bedf9fc --- /dev/null +++ b/.changeset/thread-after-filter.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Add `gbot thread --after ID` for exclusive client-side filtering of the bounded gateway tail, including no-op cursors and explicit gap-reset snapshots. diff --git a/README.md b/README.md index 6f688bc..196a258 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ gbot groups update Launch --title "Launch room" --hidden off gbot send Researcher "Summarize the launch status." gbot send Launch "Share your updates." gbot thread Researcher +gbot thread Researcher --after --json gbot groups delete Launch gbot bots delete Researcher gbot bots delete Writer @@ -35,6 +36,11 @@ gbot bots delete Writer `update` fields: `--name` `--description`/`--instructions` `--title` `--avatar-shape` `--avatar-color` `--notify` `--hidden`. `--description` is the UI Instructions field. +`gbot thread --after ID` filters the bounded tail locally and returns entries strictly +after that opaque entry ID. Its JSON includes `cursor`, `entryCount`, and `gapReset`. +An unchanged poll has `entryCount: 0`; an unknown or expired ID returns one bounded +snapshot with `gapReset: true`. The gateway request remains limit-only. + Run `gbot --help` for every command. ## Gateway URL policy @@ -99,6 +105,12 @@ Add `--replace` to an install command to overwrite an earlier copy. `npm run che runs the plugin gates: source validation, build, artifact validation, typecheck, and the route-unit tests, which drive both tools against a loopback fake gateway. +`gbot_thread` returns a small receipt by default: deterministic `summary`, opaque +`cursor`, `entryCount`, `gapReset`, target metadata, and an optional Markdown `path`. +Pass the cursor back as `after` for an exclusive client-side delta. Pass `full:true` +only when bounded entry bodies are needed in structured content; `Agent.Text` remains +the short summary. Unknown cursors produce a bounded snapshot with `gapReset: true`. + Auth resolves exactly as for `gbot`: `GROK_BOT_GATEWAY_URL` + `GROK_BOT_GATEWAY_TOKEN`, then the Grok Bot app session, then `CURSOR_ACCESS_TOKEN`. The MCP server therefore needs outbound HTTPS to the gateway host and read access to the app-session file @@ -135,6 +147,9 @@ gbot history --path `history` works offline. Use `--history-dir` / `GROK_BOT_HISTORY_DIR` to relocate, `--no-history` to skip one command. New dirs are `0700`, files `0600`. Conversation text is recorded as you typed it; gateway credentials and raw response metadata are not. +When this same opt-in is visible to the plugin, `gbot_thread` also maintains one bounded +Markdown view under `thread-artifacts/` and returns its path. With recording disabled, +the receipt omits `path`; the Markdown file is an output view, not a watermark ledger. ## License diff --git a/plugin/src/skills/talk-to-grok-bot/SKILL.md b/plugin/src/skills/talk-to-grok-bot/SKILL.md index 767012a..1a887a1 100644 --- a/plugin/src/skills/talk-to-grok-bot/SKILL.md +++ b/plugin/src/skills/talk-to-grok-bot/SKILL.md @@ -16,7 +16,11 @@ Do not ping a bot for work you can finish yourself. Replies are asynchronous — ## How 1. `gbot_send` with `target` (name or id) and `message` (first line: who you are + what you need). -2. Later, `gbot_thread` with the same `target` (`limit` defaults to 40). It returns a short summary plus a `cursor` and omits entries by default; pass `full: true` to return entries and read their text. Bot replies are `send-message` entries; yours are `message` with `role: user`. +2. Later, call `gbot_thread` with the same `target` (`limit` defaults to 40). The default receipt has `summary`, `cursor`, `entryCount`, `gapReset`, and an optional Markdown `path`; it never includes entries. +3. Poll with the previous `cursor` as `after`. This is exclusive and client-side: `entryCount: 0` means no change. `gapReset: true` means the cursor fell outside the bounded tail, so the result represents one fresh snapshot. +4. Read `path` when present. Pass `full: true` only when entry bodies are needed inline; it adds bounded `entries` to structured content, not to `Agent.Text`. + +Artifacts use the existing opt-in history root. Without `GROK_BOT_HISTORY=on`, `path` is omitted rather than creating another local ledger. Bot replies are `send-message` entries; yours are `message` with `role: user`. List targets with `gbot bots list` / `gbot groups list` when the name is ambiguous. From d15d261a94892ab9eb4a600cee302c2bd7831051 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 05:22:17 +0000 Subject: [PATCH 9/9] Subtract thread artifact and harden cursor gaps Co-authored-by: Zack Jackson --- README.md | 8 +- plugin/src/gbot.ts | 116 ++---------------- plugin/src/mcp/grok-bot/tools/gbot_thread.tsx | 29 ++--- plugin/src/skills/talk-to-grok-bot/SKILL.md | 8 +- plugin/tests/route-unit/tools.test.ts | 80 +++--------- src/transcript.js | 6 +- test/transcript.test.js | 10 ++ 7 files changed, 52 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index 196a258..cabe478 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,11 @@ runs the plugin gates: source validation, build, artifact validation, typecheck, the route-unit tests, which drive both tools against a loopback fake gateway. `gbot_thread` returns a small receipt by default: deterministic `summary`, opaque -`cursor`, `entryCount`, `gapReset`, target metadata, and an optional Markdown `path`. +`cursor`, `entryCount`, and `gapReset`. Pass the cursor back as `after` for an exclusive client-side delta. Pass `full:true` only when bounded entry bodies are needed in structured content; `Agent.Text` remains -the short summary. Unknown cursors produce a bounded snapshot with `gapReset: true`. +the short summary. Unknown cursors set `gapReset: true`; repeat that call with +`full:true` to inspect the bounded reset snapshot. Auth resolves exactly as for `gbot`: `GROK_BOT_GATEWAY_URL` + `GROK_BOT_GATEWAY_TOKEN`, then the Grok Bot app session, then `CURSOR_ACCESS_TOKEN`. The MCP server therefore @@ -147,9 +148,6 @@ gbot history --path `history` works offline. Use `--history-dir` / `GROK_BOT_HISTORY_DIR` to relocate, `--no-history` to skip one command. New dirs are `0700`, files `0600`. Conversation text is recorded as you typed it; gateway credentials and raw response metadata are not. -When this same opt-in is visible to the plugin, `gbot_thread` also maintains one bounded -Markdown view under `thread-artifacts/` and returns its path. With recording disabled, -the receipt omits `path`; the Markdown file is an output view, not a watermark ledger. ## License diff --git a/plugin/src/gbot.ts b/plugin/src/gbot.ts index 06bfa16..2041913 100644 --- a/plugin/src/gbot.ts +++ b/plugin/src/gbot.ts @@ -1,11 +1,6 @@ -import { createHash } from 'node:crypto'; -import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - import { z } from 'zod'; import { connectGateway, getTranscriptTail, sendPrompt } from 'grok-bot-cli/src/gateway.js'; -import { historyPath } from 'grok-bot-cli/src/history.js'; import { entryText, transcriptDelta, transcriptEntries as unwrapEntries } from 'grok-bot-cli/src/transcript.js'; import { redactSecrets } from 'grok-bot-cli/src/url-policy.js'; @@ -52,25 +47,11 @@ export const entrySchema = z.object({ }); type Entry = z.infer; -/** Match `gbot thread` CLI preview width so MCP hosts are not flooded. */ -export const ENTRY_TEXT_MAX = 400; -// ponytail: fixed preview/full budgets; upgrade path is a paged thread resource instead of wider caps. -// When an entry is cut by these budgets it still reports truncated/fullLength, and the -// remainder is retrievable with `gbot thread --full` / `--json` on the machine. export const ENTRY_FULL_MAX = 20000; export const TRANSCRIPT_TOTAL_MAX = 200000; // Metadata fields are capped too: an uncapped id/kind/role would bypass the total budget. export const ENTRY_META_MAX = 200; export const RECEIPT_CURSOR_MAX = 1024; -export const RECEIPT_PATH_MAX = 1024; -export const RECEIPT_SUMMARY_MAX = 256; -export const RECEIPT_MAX_BYTES = 4096; -export const THREAD_ARTIFACT_MAX_BYTES = 256 * 1024; - -export const truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => { - if (text.length <= max) return text; - return `${text.slice(0, Math.max(0, max - 1))}…`; -}; const entryFields = z.object({ id: z.string().default(''), @@ -81,11 +62,11 @@ const entryFields = z.object({ const capMeta = (value: string): string => (value.length > ENTRY_META_MAX ? `${value.slice(0, ENTRY_META_MAX)}…` : value); -const threadEntry = (raw: unknown, max: number, ellipsis: boolean): Entry => { +const threadEntry = (raw: unknown): Entry => { const fields = entryFields.safeParse(raw); const full = entryText(raw); - const truncated = full.length > max; - const text = !truncated ? full : ellipsis ? truncateEntryText(full, max) : full.slice(0, max); + const truncated = full.length > ENTRY_FULL_MAX; + const text = truncated ? full.slice(0, ENTRY_FULL_MAX) : full; if (!fields.success) { return { id: '', kind: 'unknown', text, truncated, fullLength: full.length }; } @@ -103,100 +84,17 @@ const threadEntry = (raw: unknown, max: number, ellipsis: boolean): Entry => { const metaLength = (entry: Entry): number => entry.id.length + entry.kind.length + (entry.role?.length ?? 0); -export const transcriptEntries = (transcript: unknown, opts: { full?: boolean; limit?: number } = {}): Entry[] => { +export const transcriptEntries = (transcript: unknown): Entry[] => { const rows = unwrapEntries(transcript); - // Enforce the requested count locally: a gateway ignoring `limit` cannot inflate output. - const wanted = - typeof opts.limit === 'number' && Number.isInteger(opts.limit) && opts.limit > 0 ? Math.min(opts.limit, 200) : rows.length; - const perEntry = opts.full ? ENTRY_FULL_MAX : ENTRY_TEXT_MAX; let remaining = TRANSCRIPT_TOTAL_MAX; - return rows.slice(0, wanted).map((raw) => { - const entry = threadEntry(raw, perEntry, !opts.full); + return rows.map((raw) => { + const entry = threadEntry(raw); const allowText = Math.max(0, Math.min(entry.text.length, remaining - metaLength(entry))); if (allowText < entry.text.length) { - entry.text = allowText <= 0 ? '' : !opts.full ? truncateEntryText(entry.text, allowText) : entry.text.slice(0, allowText); + entry.text = allowText <= 0 ? '' : entry.text.slice(0, allowText); entry.truncated = entry.fullLength > entry.text.length; } remaining = Math.max(0, remaining - metaLength(entry) - entry.text.length); return entry; }); }; - -const artifactEnabled = (): boolean => /^(on|true|1)$/iu.test(process.env.GROK_BOT_HISTORY ?? ''); - -const boundedMarkdown = (markdown: string): string => { - const bytes = Buffer.from(markdown); - if (bytes.length <= THREAD_ARTIFACT_MAX_BYTES) return markdown; - const marker = Buffer.from('\n\n[artifact truncated]\n'); - const prefix = bytes.subarray(0, THREAD_ARTIFACT_MAX_BYTES - marker.length).toString('utf8').replace(/\uFFFD$/u, ''); - return `${prefix}${marker.toString('utf8')}`; -}; - -const renderThreadArtifact = ( - target: z.infer, - entries: readonly Entry[], - cursor: string, -): string => { - const title = `${target.kind} ${capMeta(target.name)} (${capMeta(target.id)})`; - const sections = entries.map((entry) => { - const label = entry.role ?? entry.kind; - return `## ${label}${entry.id ? ` ${entry.id}` : ''}\n\n${entry.text}`; - }); - return boundedMarkdown(`# ${title}\n\nCursor: ${cursor}\n\n${sections.join('\n\n')}\n`); -}; - -export const saveThreadArtifact = ( - target: z.infer, - entries: readonly Entry[], - cursor: string, - update: boolean, -): string | undefined => { - if (!artifactEnabled()) return undefined; - try { - const root = dirname(historyPath()); - const filename = `${createHash('sha256').update(target.id).digest('hex').slice(0, 24)}.md`; - const path = join(root, 'thread-artifacts', filename); - if (Buffer.byteLength(path, 'utf8') > RECEIPT_PATH_MAX) return undefined; - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - if (!update && existsSync(path)) { - const stat = lstatSync(path); - return stat.isFile() && !stat.isSymbolicLink() ? path : undefined; - } - const fd = openSync(path, constants.O_CREAT | constants.O_TRUNC | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); - try { - writeFileSync(fd, renderThreadArtifact(target, entries, cursor), 'utf8'); - } finally { - closeSync(fd); - } - return path; - } catch { - return undefined; - } -}; - -export const threadSummary = ( - entryCount: number, - after: string | undefined, - gapReset: boolean, - path: string | undefined, -): string => { - if (after !== undefined && entryCount === 0 && !gapReset) return '0 new'; - const count = after !== undefined && !gapReset ? `${entryCount} new` : `${entryCount} entries`; - return `${count}${gapReset ? '; gap reset' : ''}; ${path ? 'document updated' : 'artifact unavailable'}`; -}; - -export const assertReceiptBudget = (receipt: { - cursor: string; - path?: string; - summary: string; - [key: string]: unknown; -}): void => { - if (receipt.cursor.length > RECEIPT_CURSOR_MAX) throw new Error('Thread cursor exceeds 1024 characters'); - if (receipt.summary.length > RECEIPT_SUMMARY_MAX) throw new Error('Thread summary exceeds 256 characters'); - if (receipt.path !== undefined && Buffer.byteLength(receipt.path, 'utf8') > RECEIPT_PATH_MAX) { - throw new Error('Thread artifact path exceeds 1024 bytes'); - } - if (Buffer.byteLength(JSON.stringify(receipt), 'utf8') > RECEIPT_MAX_BYTES) { - throw new Error('Thread receipt exceeds 4096 bytes'); - } -}; diff --git a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx index 37bb2f2..cb074ff 100644 --- a/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx +++ b/plugin/src/mcp/grok-bot/tools/gbot_thread.tsx @@ -3,17 +3,10 @@ import { defineTool } from 'agent-bundle/routes'; import { z } from 'zod'; import { - assertReceiptBudget, connectGateway, entrySchema, getTranscriptTail, RECEIPT_CURSOR_MAX, - RECEIPT_PATH_MAX, - RECEIPT_SUMMARY_MAX, - saveThreadArtifact, - summarizeTarget, - targetSchema, - threadSummary, transcriptDelta, transcriptEntries, withRedactedErrors, @@ -60,33 +53,25 @@ export default defineTool( entries: z.array(entrySchema).optional(), entryCount: z.number().int().min(0).max(200), gapReset: z.boolean(), - path: z.string().max(RECEIPT_PATH_MAX).optional(), - summary: z.string().max(RECEIPT_SUMMARY_MAX), - target: targetSchema, + summary: z.string().max(256), }), title: 'Read a Grok Bot thread', }, async ({ after, limit, target, full }) => { const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit)); const delta = transcriptDelta(tail.transcript, { after, limit }); - const entries = transcriptEntries(delta.entries, { full: true }); - const summarized = summarizeTarget(tail.target); - const path = saveThreadArtifact( - summarized, - entries, - delta.cursor, - after === undefined || delta.entryCount > 0 || delta.gapReset, - ); - const summary = threadSummary(delta.entryCount, after, delta.gapReset, path); + const entries = transcriptEntries(delta.entries); + const summary = delta.gapReset + ? `${delta.entryCount} entries; gap reset` + : after === undefined + ? `${delta.entryCount} entries` + : `${delta.entryCount} new`; const receipt = { cursor: delta.cursor, entryCount: delta.entryCount, gapReset: delta.gapReset, - ...(path === undefined ? {} : { path }), summary, - target: summarized, }; - assertReceiptBudget(receipt); if (!full) { return ( diff --git a/plugin/src/skills/talk-to-grok-bot/SKILL.md b/plugin/src/skills/talk-to-grok-bot/SKILL.md index 1a887a1..1ff6440 100644 --- a/plugin/src/skills/talk-to-grok-bot/SKILL.md +++ b/plugin/src/skills/talk-to-grok-bot/SKILL.md @@ -16,11 +16,11 @@ Do not ping a bot for work you can finish yourself. Replies are asynchronous — ## How 1. `gbot_send` with `target` (name or id) and `message` (first line: who you are + what you need). -2. Later, call `gbot_thread` with the same `target` (`limit` defaults to 40). The default receipt has `summary`, `cursor`, `entryCount`, `gapReset`, and an optional Markdown `path`; it never includes entries. -3. Poll with the previous `cursor` as `after`. This is exclusive and client-side: `entryCount: 0` means no change. `gapReset: true` means the cursor fell outside the bounded tail, so the result represents one fresh snapshot. -4. Read `path` when present. Pass `full: true` only when entry bodies are needed inline; it adds bounded `entries` to structured content, not to `Agent.Text`. +2. Later, call `gbot_thread` with the same `target` (`limit` defaults to 40). The default receipt has only `summary`, `cursor`, `entryCount`, and `gapReset`; it never includes entries. +3. Poll with the previous `cursor` as `after`. This is exclusive and client-side: `entryCount: 0` means no change. +4. Pass `full: true` only when entry bodies are needed inline; it adds bounded `entries` to structured content, not to `Agent.Text`. If `gapReset` is true, repeat the same call with `full: true` to inspect the bounded reset snapshot. -Artifacts use the existing opt-in history root. Without `GROK_BOT_HISTORY=on`, `path` is omitted rather than creating another local ledger. Bot replies are `send-message` entries; yours are `message` with `role: user`. +Bot replies are `send-message` entries; yours are `message` with `role: user`. List targets with `gbot bots list` / `gbot groups list` when the name is ambiguous. diff --git a/plugin/tests/route-unit/tools.test.ts b/plugin/tests/route-unit/tools.test.ts index 3c67fc1..cbeefb6 100644 --- a/plugin/tests/route-unit/tools.test.ts +++ b/plugin/tests/route-unit/tools.test.ts @@ -1,8 +1,5 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { createServer, type IncomingMessage, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@rstest/core'; import { invokeMcpTool, listMcpSurface } from 'agent-bundle/test'; @@ -88,13 +85,7 @@ const responses: Record) => [number, unkn const calls: GatewayCall[] = []; let server: Server; const savedEnv: Record = {}; -const envKeys = [ - 'GROK_BOT_GATEWAY_URL', - 'GROK_BOT_GATEWAY_TOKEN', - 'GROK_BOT_ALLOW_LOCAL_GATEWAY', - 'GROK_BOT_HISTORY', - 'GROK_BOT_HISTORY_DIR', -]; +const envKeys = ['GROK_BOT_GATEWAY_URL', 'GROK_BOT_GATEWAY_TOKEN', 'GROK_BOT_ALLOW_LOCAL_GATEWAY']; const contentText = (content: readonly { readonly text?: string }[]): string => content.map((block) => block.text ?? '').join('\n'); @@ -123,8 +114,6 @@ beforeAll(async () => { process.env.GROK_BOT_GATEWAY_URL = `http://127.0.0.1:${port}`; process.env.GROK_BOT_GATEWAY_TOKEN = 'test-token'; process.env.GROK_BOT_ALLOW_LOCAL_GATEWAY = '1'; - process.env.GROK_BOT_HISTORY = 'off'; - delete process.env.GROK_BOT_HISTORY_DIR; }); afterAll(async () => { @@ -175,12 +164,11 @@ describe('grok-bot MCP server', () => { cursor: 't3', entryCount: 3, gapReset: false, - summary: '3 entries; artifact unavailable', - target: { id: 'grp-1', kind: 'group', name: 'Launch' }, + summary: '3 entries', }); expect(calls[1]).toMatchObject({ body: { id: 'grp-1', limit: 3 }, method: 'getAgentTranscriptTail' }); const summary = contentText(result.content); - expect(summary).toBe('3 entries; artifact unavailable'); + expect(summary).toBe('3 entries'); expect(summary).not.toContain('hello from the test'); expect(summary).not.toContain('reply'); @@ -198,10 +186,9 @@ describe('grok-bot MCP server', () => { { id: 't3', kind: 'tool-call', text: '', truncated: false, fullLength: 0 }, ], gapReset: false, - summary: '3 entries; artifact unavailable', - target: { id: 'grp-1', kind: 'group', name: 'Launch' }, + summary: '3 entries', }); - expect(contentText(full.content)).toBe('3 entries; artifact unavailable'); + expect(contentText(full.content)).toBe('3 entries'); }); it('gbot_thread defaults the limit to 40 like the CLI and reads the other transcript shapes', async () => { @@ -211,21 +198,19 @@ describe('grok-bot MCP server', () => { cursor: '', entryCount: 0, gapReset: false, - summary: '0 entries; artifact unavailable', - target: { id: 'bot-1', kind: 'bot', name: 'General' }, + summary: '0 entries', }); - expect(contentText(empty.content)).toBe('0 entries; artifact unavailable'); + expect(contentText(empty.content)).toBe('0 entries'); const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); expect(legacy.structuredContent).toEqual({ cursor: 'l4', entryCount: 4, gapReset: false, - summary: '4 entries; artifact unavailable', - target: { id: 'bot-2', kind: 'bot', name: 'Legacy' }, + summary: '4 entries', }); const legacySummary = contentText(legacy.content); - expect(legacySummary).toBe('4 entries; artifact unavailable'); + expect(legacySummary).toBe('4 entries'); expect(legacySummary).not.toContain('direct text'); expect(legacySummary).not.toContain('…'); expect(legacySummary).not.toContain('x'.repeat(450)); @@ -252,10 +237,9 @@ describe('grok-bot MCP server', () => { { id: 'o3', kind: 'note', text: 'a�b', truncated: false, fullLength: 3 }, ], gapReset: false, - summary: '3 entries; artifact unavailable', - target: { id: 'bot-4', kind: 'bot', name: 'Odd' }, + summary: '3 entries', }); - expect(contentText(odd.content)).toBe('3 entries; artifact unavailable'); + expect(contentText(odd.content)).toBe('3 entries'); }); it('gbot_thread returns tiny no-op receipts and exclusive deltas without sending after upstream', async () => { @@ -268,7 +252,7 @@ describe('grok-bot MCP server', () => { expect(JSON.stringify(structured)).not.toContain('update 1'); expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThan(4096); const summaryText = contentText(summary.content); - expect(summaryText).toBe('60 entries; artifact unavailable'); + expect(summaryText).toBe('60 entries'); expect(summaryText).not.toContain('update 1'); const newer = await invokeMcpTool('gbot_thread', { @@ -280,7 +264,7 @@ describe('grok-bot MCP server', () => { cursor: 'n60', entryCount: 2, gapReset: false, - summary: '2 new; artifact unavailable', + summary: '2 new', }); expect((newer.structuredContent as { entries: { id: string }[] }).entries.map((entry) => entry.id)).toEqual(['n59', 'n60']); @@ -319,7 +303,7 @@ describe('grok-bot MCP server', () => { cursor: 'n60', entryCount: 40, gapReset: true, - summary: '40 entries; gap reset; artifact unavailable', + summary: '40 entries; gap reset', }); const entries = (reset.structuredContent as { entries: { id: string }[] }).entries; expect(entries).toHaveLength(40); @@ -327,36 +311,6 @@ describe('grok-bot MCP server', () => { expect(entries[39]?.id).toBe('n60'); }); - it('gbot_thread writes a bounded artifact only under the opt-in history root', async () => { - const dir = mkdtempSync(join(tmpdir(), 'gbot-thread-artifact-')); - process.env.GROK_BOT_HISTORY = 'on'; - process.env.GROK_BOT_HISTORY_DIR = dir; - try { - const result = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' }); - expect(result.isError).toBe(false); - const receipt = result.structuredContent as { path?: string; summary: string }; - expect(receipt.path?.startsWith(join(dir, 'thread-artifacts'))).toBe(true); - expect(receipt.path?.length).toBeLessThanOrEqual(1024); - expect(receipt.summary).toBe('4 entries; document updated'); - expect(existsSync(receipt.path ?? '')).toBe(true); - const markdown = readFileSync(receipt.path ?? '', 'utf8'); - expect(markdown).toContain('direct text'); - expect(markdown).toContain('x'.repeat(450)); - expect(statSync(receipt.path ?? '').size).toBeLessThanOrEqual(256 * 1024); - - const unchanged = await invokeMcpTool('gbot_thread', { - input: { after: 'l4', target: 'Legacy' }, - server: 'grok-bot', - }); - expect(unchanged.structuredContent).toMatchObject({ entryCount: 0, path: receipt.path, summary: '0 new' }); - expect(readFileSync(receipt.path ?? '', 'utf8')).toBe(markdown); - } finally { - process.env.GROK_BOT_HISTORY = 'off'; - delete process.env.GROK_BOT_HISTORY_DIR; - rmSync(dir, { force: true, recursive: true }); - } - }); - it('gbot_send stays unknown when the gateway confirms no receipt', async () => { const result = await invokeMcpTool('gbot_send', { input: { message: 'ping', target: 'Noreceipt' }, @@ -403,8 +357,7 @@ describe('grok-bot MCP server', () => { cursor: 'last-valid-id', entryCount: 2, gapReset: false, - summary: '2 entries; artifact unavailable', - target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, + summary: '2 entries', }); const summaryText = contentText(summary.content); expect(summaryText).not.toContain('i'.repeat(300)); @@ -427,8 +380,7 @@ describe('grok-bot MCP server', () => { { id: '', kind: 'unknown', text: '', truncated: false, fullLength: 0 }, ], gapReset: false, - summary: '2 entries; artifact unavailable', - target: { id: 'bot-7', kind: 'bot', name: 'Meta' }, + summary: '2 entries', }); }); diff --git a/src/transcript.js b/src/transcript.js index 7442a21..c771f9d 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -67,12 +67,16 @@ export function transcriptDelta(payload, { after, limit = 40 } = /** @type {{ af const bounded = Math.min(Math.max(Math.trunc(limit) || 40, 1), 200); const page = transcriptEntries(payload).slice(-bounded); if (after === undefined) { - return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: false }; + const gapReset = page.length > 0 && !sourceEntryId(page[page.length - 1]); + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset }; } const afterIndex = page.findIndex((entry) => sourceEntryId(entry) === after); if (afterIndex === -1) { return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: true }; } const entries = page.slice(afterIndex + 1); + if (entries.length > 0 && !sourceEntryId(entries[entries.length - 1])) { + return { cursor: lastSourceId(page), entries: page, entryCount: page.length, gapReset: true }; + } return { cursor: lastSourceId(entries, after), entries, entryCount: entries.length, gapReset: false }; } diff --git a/test/transcript.test.js b/test/transcript.test.js index d37feb9..ea8220a 100644 --- a/test/transcript.test.js +++ b/test/transcript.test.js @@ -67,6 +67,16 @@ test("transcript delta filters exclusively after a known cursor and keeps no-op }); }); +test("transcript delta resets when trailing idless entries cannot advance the cursor", () => { + const entries = [{ id: "seen", text: "seen" }, { text: "new but idless" }]; + assert.deepEqual(transcriptDelta({ entries }, { after: "seen", limit: 40 }), { + cursor: "seen", + entries, + entryCount: 2, + gapReset: true, + }); +}); + test("transcript delta resets an unknown cursor with one bounded snapshot", () => { const entries = Array.from({ length: 45 }, (_, index) => ({ id: `m${index + 1}`, text: `message ${index + 1}` })); const delta = transcriptDelta({ entries }, { after: "bogus", limit: 40 });