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
5 changes: 5 additions & 0 deletions .changeset/potato-p1-hold-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

HOLD-fix follow-up for the P1 bridge work: gateway reads count true bytes through a streaming reader that cancels on overflow; Codex transport uses an absolute handshake deadline, caps terminated headers and complete frames before decoding, guarantees socket destruction on close, and routes malformed RPC through failure handling; CLI `--json` failures emit structured errors with delivery and IDs; sends stay `unknown` without a confirmed receipt; MCP full reads add aggregate budgets with truncation metadata and a CLI continuation path.
40 changes: 32 additions & 8 deletions plugin/src/gbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ 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.
// 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 truncateEntryText = (text: string, max = ENTRY_TEXT_MAX): string => {
if (text.length <= max) return text;
Expand All @@ -64,26 +69,45 @@ const entryFields = z.object({
timestampMs: z.number().optional(),
});

const threadEntry = (raw: unknown, opts: { full?: boolean } = {}): Entry => {
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 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);
const text = !truncated ? full : ellipsis ? truncateEntryText(full, max) : full.slice(0, max);
if (!fields.success) {
return { id: '', kind: 'unknown', text, truncated, fullLength: full.length };
}
const { id, kind, role, timestampMs } = fields.data;
return {
id,
kind,
...(role === undefined ? {} : { role }),
id: capMeta(id),
kind: capMeta(kind),
...(role === undefined ? {} : { role: capMeta(role) }),
text,
truncated,
fullLength: full.length,
...(timestampMs === undefined ? {} : { timestampMs }),
};
};

export const transcriptEntries = (transcript: unknown, opts: { full?: boolean } = {}): Entry[] =>
unwrapEntries(transcript).map((raw) => threadEntry(raw, opts));
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[] => {
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);
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.truncated = entry.fullLength > entry.text.length;
}
remaining = Math.max(0, remaining - metaLength(entry) - entry.text.length);
return entry;
});
};
8 changes: 5 additions & 3 deletions plugin/src/mcp/grok-bot/tools/gbot_send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@ export default defineTool(
resultSchema: z.object({
result: z.record(z.string(), z.json()),
target: targetSchema,
delivery: z.enum(['accepted']),
delivery: z.enum(['accepted', 'unknown']),
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 messageId = typeof sent.messageId === 'string' ? (sent.messageId as string) : undefined;
// Never claim acceptance the gateway didn't confirm; no-receipt sends stay unknown.
const delivery = sent.delivery === 'accepted' ? ('accepted' as const) : ('unknown' as const);
const messageId = delivery === 'accepted' && typeof sent.messageId === 'string' ? (sent.messageId as string) : undefined;
const value = {
result: sent.result,
target: summarizeTarget(sent.target),
delivery: 'accepted' as const,
delivery,
...(messageId === undefined ? {} : { messageId }),
};
return (
Expand Down
6 changes: 4 additions & 2 deletions plugin/src/mcp/grok-bot/tools/gbot_thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default defineTool(
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.',
'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.',
type: 'boolean',
},
target: { description: 'Bot or group name or id, for example "General".', type: 'string' },
Expand All @@ -37,6 +37,8 @@ export default defineTool(
type: 'object',
},
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.
limit: z.number().int().min(1).max(200).default(40),
full: z.boolean().default(false),
target: z.string().min(1),
Expand All @@ -46,7 +48,7 @@ export default defineTool(
},
async ({ limit, target, full }) => {
const tail = await withRedactedErrors(async () => getTranscriptTail(await connectGateway(), target, limit));
const value = { entries: transcriptEntries(tail.transcript, { full }), target: summarizeTarget(tail.target) };
const value = { entries: transcriptEntries(tail.transcript, { full, limit }), 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
69 changes: 68 additions & 1 deletion plugin/tests/route-unit/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const roster = {
{ id: 'bot-2', isGroup: false, name: 'Legacy' },
{ id: 'bot-3', isGroup: false, name: 'Proxy' },
{ id: 'bot-4', isGroup: false, name: 'Odd' },
{ id: 'bot-5', isGroup: false, name: 'Noreceipt' },
{ id: 'bot-6', isGroup: false, name: 'Big' },
{ id: 'bot-7', isGroup: false, name: 'Meta' },
],
};
const transcripts: Record<string, unknown> = {
Expand Down Expand Up @@ -50,14 +53,22 @@ const transcripts: Record<string, unknown> = {
{ id: 'o3', kind: 'note', text: `a${String.fromCharCode(0xd800)}b` },
],
},
'bot-6': {
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' }],
},
};
const responses: Record<string, (body: Record<string, unknown>) => [number, unknown]> = {
getAgentTranscriptTail: (body) => [200, transcripts[String(body.id)]],
listAgents: () => [200, roster],
sendPrompt: (body) =>
body.agentId === 'bot-3'
? [401, { message: 'upstream rejected authorization: Bearer test-token' }]
: [200, { messageId: 'm-1' }],
: body.agentId === 'bot-5'
? [200, { ok: true }]
: [200, { messageId: 'm-1' }],
};

const calls: GatewayCall[] = [];
Expand Down Expand Up @@ -191,6 +202,62 @@ describe('grok-bot MCP server', () => {
});
});

it('gbot_send stays unknown when the gateway confirms no receipt', async () => {
const result = await invokeMcpTool('gbot_send', {
input: { message: 'ping', target: 'Noreceipt' },
server: 'grok-bot',
});
expect(result.isError).toBe(false);
expect(result.structuredContent).toEqual({
delivery: 'unknown',
result: { ok: true },
target: { id: 'bot-5', kind: 'bot', name: 'Noreceipt' },
});
expect(contentText(result.content)).toContain('no receipt');
expect(contentText(result.content)).not.toContain(' as ');
});

it('gbot_thread caps aggregate output and keeps the remainder visible in metadata', async () => {
const full = await invokeMcpTool('gbot_thread', {
input: { full: true, target: 'Big' },
server: 'grok-bot',
});
expect(full.isError).toBe(false);
const entries = (full.structuredContent as { entries: { id: string; text: string; truncated: boolean; fullLength: number }[] }).entries;
expect(entries.length).toBe(11);
expect(entries[9]?.text.length).toBe(19940);
expect(entries[9]).toMatchObject({ id: 'b9', truncated: true, fullLength: 20000 });
expect(entries[10]).toEqual({ id: 'b10', kind: 'note', text: '', truncated: true, fullLength: 20000 });
});

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' },
server: 'grok-bot',
});
expect(capped.isError).toBe(false);
const entries = (capped.structuredContent as { entries: unknown[] }).entries;
expect(entries.length).toBe(3);
});

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' });
expect(odd.isError).toBe(false);
expect(odd.structuredContent).toEqual({
entries: [
{
id: `${'i'.repeat(200)}…`,
kind: `${'k'.repeat(200)}…`,
role: `${'R'.repeat(200)}…`,
text: 'hi',
truncated: false,
fullLength: 2,
},
],
target: { id: 'bot-7', kind: 'bot', name: 'Meta' },
});
});

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
44 changes: 38 additions & 6 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ function print(value) {
function fail(err) {
let message = err instanceof Error ? err.message : String(err);
message = redactSecrets(message);
process.stderr.write(message + "\n");
if (jsonErrors && err instanceof Error) {
const out = { error: message };
if (err.delivery !== undefined) out.delivery = err.delivery;
if (err.threadId !== undefined) out.threadId = err.threadId;
if (err.turnId !== undefined) out.turnId = err.turnId;
if (err.targetId !== undefined) out.targetId = err.targetId;
process.stderr.write(JSON.stringify(out) + "\n");
} else {
process.stderr.write(message + "\n");
}
process.exit(1);
}

Expand Down Expand Up @@ -118,7 +127,19 @@ function hasFlag(args, name) {
return true;
}

/** Peel a trailing flag that `--` does not protect; free-text commands keep mid-text tokens. */
function takeTrailingFlag(args, name) {
const stop = args.indexOf("--");
const end = stop === -1 ? args.length : stop;
if (end > 0 && args[end - 1] === name) {
args.splice(end - 1, 1);
return true;
}
return false;
}

/** Peel global CLI options only from the leading argv (before the command). */
let jsonErrors = false;
function takeLeadingGlobals(args) {
let json = false;
let gateway = false;
Expand All @@ -134,6 +155,7 @@ function takeLeadingGlobals(args) {
}
if (a === "--json") {
json = true;
jsonErrors = true;
args.shift();
continue;
}
Expand Down Expand Up @@ -301,6 +323,11 @@ function formatCodexThread(t) {
}

async function runCodex(sub, rest, json) {
// Structured subcommands take no free text, so --json peels anywhere. Send
// peels a trailing --json only; mid-message tokens stay message content.
if (sub === "status" || sub === "list-threads") {
if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
}
if (sub === "status") {
const status = await codexStatus();
print(json ? status : formatCodexStatus(status));
Expand All @@ -319,6 +346,7 @@ async function runCodex(sub, rest, json) {
}
if (sub === "send") {
const threadId = rest.shift();
if (takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; }
if (rest[0] === "--") rest.shift();
const message = rest.join(" ").trim();
if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send <threadId> <message...>");
Expand Down Expand Up @@ -379,7 +407,7 @@ async function main(argv) {
if (cmd === "history") {
const options = args.slice(1);
// Command-local flags (globals only peel from argv before the command).
if (hasFlag(options, "--json")) json = true;
if (hasFlag(options, "--json")) { json = true; jsonErrors = true; }
const showPath = hasFlag(options, "--path");
const search = takeFlag(options, "--search");
const limitRaw = takeFlag(options, "--limit");
Expand All @@ -405,6 +433,12 @@ async function main(argv) {
return;
}

// Peel command-local --json before touching the backend so auth/gateway
// failures honor it. Send peels a trailing --json only (mid-text tokens stay
// message content); `--` protects everything after it.
if (cmd === "send" && takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; }
if ((cmd === "thread" || cmd === "chat") && hasFlag(rest, "--json")) { json = true; jsonErrors = true; }

const backend = await openBackend({ root: rootFlag, gateway, files: filesMode });

if (cmd === "bots" && sub === "list") {
Expand Down Expand Up @@ -514,7 +548,7 @@ async function main(argv) {

if (cmd === "send") {
const ref = sub;
if (hasFlag(rest, "--json")) json = true;
if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Parse trailing JSON mode before opening the backend

When callers use the supported command-local form gbot send ... --json or gbot thread ... --json, this flag is not processed until after openBackend() has run. If backend initialization fails—for example, because the agents directory is missing or the app gateway session is unusable—jsonErrors remains false and the CLI emits plaintext instead of the promised structured JSON error. Parse these command-local flags before opening the backend.

Useful? React with 👍 / 👎.

const message = rest.join(" ").trim();
if (!ref || !message) throw new StoreError("gbot send <bot-or-group> <message...>");
const out = await backend.send(ref, message);
Expand All @@ -528,9 +562,7 @@ async function main(argv) {
if (cmd === "thread" || cmd === "chat") {
const ref = sub;
if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]");
if (hasFlag(rest, "--json")) json = true;
const full = rest.includes("--full");
if (full) rest.splice(rest.indexOf("--full"), 1);
const full = hasFlag(rest, "--full");
const limitRaw = takeFlag(rest, "--limit");
const rootId = takeFlag(rest, "--root");
const limit = limitRaw ? Number(limitRaw) : 40;
Expand Down
Loading