Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/potato-p1-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"grok-bot-cli": patch
---
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the opening Changesets frontmatter delimiter

This file starts directly with the package release entry instead of ---, unlike every other changeset in the repository. Changesets therefore cannot parse this release metadata, blocking commands such as changeset status, changeset version, and the release workflow until the opening delimiter is added.

Useful? React with 👍 / 👎.


P1 bridge follow-ups: preserve send receipts with rejected/accepted/unknown delivery states (Codex `CodexSendError` keeps thread/turn IDs, gateway `sendPrompt` returns `delivery` + `messageId` and marks post-write loss unknown); bound the Codex WebSocket transport (idempotent close settling pending requests, socket destroy on every failure, exact handshake validation, fragmentation/UTF-8/opcode handling, header/frame/message/buffer budgets); make `gbot_thread` bounded without losing replies (truncation metadata, `full` bounded full-read in tool and `--full` in CLI, safe string normalization, 1–200 limit consistency, gateway deadline and response-byte cap).
29 changes: 24 additions & 5 deletions plugin/src/gbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ export const withRedactedErrors = async <T>(run: () => Promise<T>): Promise<T> =
try {
return await run();
} catch (error) {
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error)));
const wrapped: Error & { delivery?: string; targetId?: string } = new Error(
redactSecrets(error instanceof Error ? error.message : String(error)),
);
if (error instanceof Error) {
const src = error as Error & { delivery?: unknown; targetId?: unknown };
if (typeof src.delivery === 'string') wrapped.delivery = src.delivery;
if (typeof src.targetId === 'string') wrapped.targetId = src.targetId;
}
throw wrapped;
}
};

Expand All @@ -33,12 +41,16 @@ export const entrySchema = z.object({
kind: z.string(),
role: z.string().optional(),
text: z.string(),
truncated: z.boolean(),
fullLength: z.number().int().min(0),
timestampMs: z.number().optional(),
});
type Entry = z.infer<typeof entrySchema>;

/** 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.
export const ENTRY_FULL_MAX = 20000;

export const truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => {
if (text.length <= max) return text;
Expand All @@ -52,19 +64,26 @@ const entryFields = z.object({
timestampMs: z.number().optional(),
});

const threadEntry = (raw: unknown): Entry => {
const threadEntry = (raw: unknown, opts: { full?: boolean } = {}): Entry => {
const fields = entryFields.safeParse(raw);
const full = entryText(raw);
const max = opts.full ? ENTRY_FULL_MAX : ENTRY_TEXT_MAX;
const truncated = full.length > max;
const text = !truncated ? full : opts.full ? full.slice(0, max) : truncateEntryText(full);
if (!fields.success) {
return { id: '', kind: 'unknown', text: truncateEntryText(JSON.stringify(raw)) };
return { id: '', kind: 'unknown', text, truncated, fullLength: full.length };
}
const { id, kind, role, timestampMs } = fields.data;
return {
id,
kind,
...(role === undefined ? {} : { role }),
text: truncateEntryText(entryText(raw)),
text,
truncated,
fullLength: full.length,
...(timestampMs === undefined ? {} : { timestampMs }),
};
};

export const transcriptEntries = (transcript: unknown): Entry[] => unwrapEntries(transcript).map(threadEntry);
export const transcriptEntries = (transcript: unknown, opts: { full?: boolean } = {}): Entry[] =>
unwrapEntries(transcript).map((raw) => threadEntry(raw, opts));
17 changes: 14 additions & 3 deletions plugin/src/mcp/grok-bot/tools/gbot_send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,27 @@ export default defineTool(
type: 'object',
},
inputSchema: z.object({ message: z.string().min(1), target: z.string().min(1) }),
resultSchema: z.object({ result: z.record(z.string(), z.json()), target: targetSchema }),
resultSchema: z.object({
result: z.record(z.string(), z.json()),
target: targetSchema,
delivery: z.enum(['accepted']),
messageId: z.string().optional(),
}),
title: 'Send a message to Grok Bot',
},
async ({ message, target }) => {
const sent = await withRedactedErrors(async () => sendPrompt(await connectGateway(), target, message));
const value = { result: sent.result, target: summarizeTarget(sent.target) };
const messageId = typeof sent.messageId === 'string' ? (sent.messageId as string) : undefined;
const value = {
result: sent.result,
target: summarizeTarget(sent.target),
delivery: 'accepted' as const,
...(messageId === undefined ? {} : { messageId }),
};
return (
<Agent.Result value={value}>
<Agent.Text>
{`Sent to ${value.target.kind} ${value.target.name} (${value.target.id}). Read the reply with gbot_thread.`}
{`Sent to ${value.target.kind} ${value.target.name} (${value.target.id})${messageId === undefined ? ' (no receipt; check the thread before resending)' : ` as ${messageId}`}. Read the reply with gbot_thread.`}
</Agent.Text>
</Agent.Result>
);
Expand Down
16 changes: 13 additions & 3 deletions plugin/src/mcp/grok-bot/tools/gbot_thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,28 @@ export default defineTool(
description: 'How many trailing entries to return (1-200). Each entry text is capped at 400 characters.',
type: 'number',
},
full: {
default: false,
description:
'Return complete entry text up to a bounded full-read budget instead of the 400-character preview. Every entry still reports truncation metadata.',
type: 'boolean',
},
target: { description: 'Bot or group name or id, for example "General".', type: 'string' },
},
required: ['target'],
type: 'object',
},
inputSchema: z.object({ limit: z.number().int().min(1).max(200).default(40), target: z.string().min(1) }),
inputSchema: z.object({
limit: z.number().int().min(1).max(200).default(40),
full: z.boolean().default(false),
target: z.string().min(1),
}),
resultSchema: z.object({ entries: z.array(entrySchema), target: targetSchema }),
title: 'Read a Grok Bot thread',
},
async ({ limit, target }) => {
async ({ limit, target, full }) => {
const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit));
const value = { entries: transcriptEntries(tail.transcript), target: summarizeTarget(tail.target) };
const value = { entries: transcriptEntries(tail.transcript, { full }), target: summarizeTarget(tail.target) };
return (
<Agent.Result value={value}>
<Agent.Text>{`${value.target.kind} ${value.target.name}: ${value.entries.length} entries.`}</Agent.Text>
Expand Down
46 changes: 39 additions & 7 deletions plugin/tests/route-unit/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const roster = {
{ id: 'grp-1', memberAgentIds: ['bot-1'], name: 'Launch' },
{ id: 'bot-2', isGroup: false, name: 'Legacy' },
{ id: 'bot-3', isGroup: false, name: 'Proxy' },
{ id: 'bot-4', isGroup: false, name: 'Odd' },
],
};
const transcripts: Record<string, unknown> = {
Expand All @@ -42,6 +43,13 @@ const transcripts: Record<string, unknown> = {
],
nextBeforeSeq: 9,
},
'bot-4': {
entries: [
{ content: { text: 5 }, id: 'o1', kind: 'note' },
{ id: 'o2', kind: 'mystery' },
{ id: 'o3', kind: 'note', text: `a${String.fromCharCode(0xd800)}b` },
],
},
};
const responses: Record<string, (body: Record<string, unknown>) => [number, unknown]> = {
getAgentTranscriptTail: (body) => [200, transcripts[String(body.id)]],
Expand Down Expand Up @@ -111,6 +119,8 @@ describe('grok-bot MCP server', () => {
});
expect(result.isError).toBe(false);
expect(result.structuredContent).toEqual({
delivery: 'accepted',
messageId: 'm-1',
result: { messageId: 'm-1' },
target: { id: 'bot-1', kind: 'bot', name: 'General' },
});
Expand All @@ -130,9 +140,9 @@ describe('grok-bot MCP server', () => {
expect(result.isError).toBe(false);
expect(result.structuredContent).toEqual({
entries: [
{ id: 't1', kind: 'message', role: 'user', text: 'hello from the test', timestampMs: 1 },
{ id: 't2', kind: 'send-message', text: 'reply' },
{ id: 't3', kind: 'tool-call', text: '' },
{ 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' },
});
Expand All @@ -149,16 +159,38 @@ describe('grok-bot MCP server', () => {
const legacy = await invokeMcpTool('gbot_thread', { input: { target: 'Legacy' }, server: 'grok-bot' });
expect(legacy.structuredContent).toMatchObject({
entries: [
{ id: 'l1', kind: 'message', text: 'direct text' },
{ id: 'l2', kind: 'note', text: 'part one\npart two\npart three' },
{ id: 'l3', kind: 'message', text: 'plain message' },
{ id: 'l4', kind: 'message', text: `${'x'.repeat(399)}…` },
{ 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 },
],
});
expect(contentText(legacy.content)).toContain('…');
expect(contentText(legacy.content)).not.toContain('x'.repeat(450));
});

it('gbot_thread recovers a complete long reply with full:true and normalizes malformed entries', async () => {
const full = await invokeMcpTool('gbot_thread', {
input: { full: true, target: 'Legacy' },
server: 'grok-bot',
});
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));

const odd = await invokeMcpTool('gbot_thread', { input: { target: 'Odd' }, server: 'grok-bot' });
expect(odd.isError).toBe(false);
expect(odd.structuredContent).toEqual({
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 },
],
target: { id: 'bot-4', kind: 'bot', name: 'Odd' },
});
});

it('redacts a bearer token echoed by the gateway before the error reaches the host', async () => {
const result = await invokeMcpTool('gbot_send', {
input: { message: 'x', target: 'Proxy' },
Expand Down
24 changes: 17 additions & 7 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function usage() {
" groups set <group> --member ID [--member ...]",
" groups delete <id-or-name>",
" send <bot-or-group> <message...>",
" thread <bot-or-group> [--limit N] [--root MESSAGE_ID]",
" thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]",
" chat <bot-or-group> alias for thread",
" codex status",
" codex list-threads [--limit N]",
Expand Down Expand Up @@ -232,7 +232,7 @@ function formatRecord(rec, all) {
return kind + " " + rec.name + title + "\n " + rec.id + desc + avatar + settingsLine + extra;
}

function formatTranscript(out) {
function formatTranscript(out, { full = false } = {}) {
const rec = out.target;
const payload = out.transcript || out.thread || {};
const entries = transcriptEntries(payload);
Expand All @@ -245,11 +245,17 @@ function formatTranscript(out) {
const role = e.role || e.kind || e.sender || e.type || "msg";
const text = entryText(e);
const id = e.id || e.messageId || "";
lines.push("[" + role + (id ? " " + id : "") + "] " + String(text).slice(0, 400));
lines.push("[" + role + (id ? " " + id : "") + "] " + (full ? text : truncateCliText(text)));
}
return lines.join("\n");
}

/** Bounded preview; --json/--full still retrieve the complete text. */
function truncateCliText(text, max = 400) {
if (text.length <= max) return text;
return text.slice(0, max) + "… [+" + (text.length - max) + " chars; --full or --json for complete text]";
}

function formatCodexStatus(s) {
const lines = ["socket: " + s.socketPath];
if (!s.reachable) return lines.concat("reachable: no", s.message).join("\n");
Expand Down Expand Up @@ -459,20 +465,24 @@ async function main(argv) {
const message = rest.join(" ").trim();
if (!ref || !message) throw new StoreError("gbot send <bot-or-group> <message...>");
const out = await backend.send(ref, message);
if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result });
else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")");
const receipt = out.messageId ? " message " + out.messageId : "";
if (json) print({ id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot", result: out.result, delivery: out.delivery || "accepted", ...(out.messageId ? { messageId: out.messageId } : {}) });
else print("Sent to " + (out.target.isGroup ? "group" : "bot") + " " + out.target.name + " (" + out.target.id + ")" + receipt);
return;
}

if (cmd === "thread" || cmd === "chat") {
const ref = sub;
if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID]");
if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]");
const full = rest.includes("--full");
if (full) rest.splice(rest.indexOf("--full"), 1);
const limitRaw = takeFlag(rest, "--limit");
const rootId = takeFlag(rest, "--root");
const limit = limitRaw ? Number(limitRaw) : 40;
if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new StoreError("--limit must be an integer 1-200");
const out = rootId ? await backend.thread(ref, rootId) : await backend.transcript(ref, limit);
if (json) print(out);
else print(formatTranscript(out));
else print(formatTranscript(out, { full }));
return;
}

Expand Down
Loading