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
21 changes: 18 additions & 3 deletions server/gjc-bun-sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { GjcBunOAuthController, type GjcBunOAuthControllerOptions } from './gjc-
import { GJC_APP_BUILTIN_COMMAND_NAMES } from './modules/providers/gjc-command-surface.generated.js';
import type { GjcWorkerOAuthRuntime, GjcWorkerRuntime, GjcWorkerWriter } from './gjc-worker.js';
import { GjcBunAskController } from './gjc-bun-ask-controller.js';
import { forwardPromptTerminal, forwardSdkEvent, type SdkRunState } from './gjc-bun-sdk-events.js';
import { forwardPromptTerminal, forwardSdkEvent, normalizeBuiltinCommandStdout, type SdkRunState } from './gjc-bun-sdk-events.js';
import { resolveContainedExportCommand } from './gjc-export-path.js';
import { readSessionSnapshot } from './gjc-session-state.js';
type Model = ReturnType<ModelRegistry['getAll']>[number];
Expand Down Expand Up @@ -344,9 +344,24 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime {
const commandMatch = /^\/([^\s]+)(?:\s+(.*))?$/.exec(message.trim());
const commandName = commandMatch?.[1];
if (commandName && GJC_APP_BUILTIN_COMMAND_NAMES.has(commandName)) {
const requestedExportPath = commandName === 'export' ? commandMatch?.[2]?.trim() : '';
const output = (text: string) => {
writer.send({ kind: 'stream_delta', content: text });
writer.send({ kind: 'stream_end' });
const content = normalizeBuiltinCommandStdout(text);
// The upstream exporter can corrupt a nested relative path in its
// own error text. The original command is the only authoritative
// source, so report that rather than trying to reconstruct it.
const corruptedExportPath = commandName === 'export'
&& requestedExportPath
&& content.includes('\uFFFD');
const safeContent = corruptedExportPath
? `Failed to export "${requestedExportPath}"${content.includes('ENOENT') ? ': ENOENT' : ''}: the upstream export command returned a corrupted path.`
: content;
writer.send({
kind: 'text',
role: 'assistant',
content: safeContent,
isLocalCommandStdout: true,
});
};
// `/export` writes through a relative path resolved against the
// worker's process cwd, and one worker serves every session. Rebind
Expand Down
7 changes: 7 additions & 0 deletions server/gjc-bun-sdk-events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { stripVTControlCharacters } from 'node:util';

import type { GjcSessionSnapshot } from './gjc-session-state.js';
import type { GjcWorkerWriter } from './gjc-worker.js';

Expand Down Expand Up @@ -49,6 +51,11 @@ const object = (value: unknown): value is RecordValue => value !== null && typeo
const str = (value: unknown): string => typeof value === 'string' ? value : '';
const num = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0;

/** Removes terminal controls from SDK builtin-command output only. */
export function normalizeBuiltinCommandStdout(value: string): string {
return stripVTControlCharacters(value);
}

/**
* Longest notice/error text forwarded to the browser. The SDK's own strings are
* short; the cap only exists so a pathological provider message cannot inflate a
Expand Down
49 changes: 48 additions & 1 deletion server/gjc-sdk-contract.bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,6 @@ test('advertised GJC builtins execute in the SDK worker without becoming model p
assert.equal(session.promptCalls, 0);
assert.deepEqual(methods(f.frames), [
'session.created',
'message.delta',
'message.completed',
'turn.completed',
'worker.status',
Expand All @@ -377,6 +376,54 @@ test('advertised GJC builtins execute in the SDK worker without becoming model p
}
});


test('builtin stdout is terminal-safe, preserves Unicode, and retains export path provenance', async () => {
const f = await fixture(
'contract-model',
undefined,
{ id: 'contract-model', provider: 'contract-provider' },
async (text, runtime) => {
if (text.startsWith('/export')) {
await runtime.output('Export failed: \uFFFD');
} else {
await runtime.output('\u001B[31m$5\t中🙂 `code` <markup>\u001B[0m\n\u001B]8;;https://example.test\u0007link\u001B]8;;\u0007');
}
return { consumed: true };
},
);
try {
await f.host.handle(request('session.start', 'builtin-stdout', {
message: '/jobs',
options: f.options,
}));
await f.host.handle(request('session.start', 'export-corruption', {
message: '/export nested/報告.html',
options: f.options,
}));

const outputs = f.frames
.filter((frame) => frame.kind === 'event' && frame.method === 'message.completed')
.map((frame) => ((frame.payload as Record<string, unknown>).message as Record<string, unknown>))
.filter((message) => message.isLocalCommandStdout === true);
assert.deepEqual(outputs, [
{
kind: 'text',
role: 'assistant',
content: '$5\t中🙂 `code` <markup>\nlink',
isLocalCommandStdout: true,
},
{
kind: 'text',
role: 'assistant',
content: 'Failed to export "nested/報告.html": the upstream export command returned a corrupted path.',
isLocalCommandStdout: true,
},
]);
} finally {
await f.close();
}
});

/*
* `/export` containment across a shared worker.
*
Expand Down
15 changes: 15 additions & 0 deletions src/components/chat/view/subcomponents/MessageComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,18 @@ test('truncated tool history offers an explicit full-output load action', () =>
assert.match(html, /Load full output/);
assert.match(html, /2048 KB/);
});


test('local command stdout renders as escaped preformatted text instead of Markdown math', () => {
const html = renderMessage({
type: 'assistant',
content: '$CacheHitRate: 0.5\n中🙂 `code` <tag>',
timestamp: '2026-08-13T00:00:00.000Z',
isLocalCommandStdout: true,
});

assert.match(html, /<pre[^>]*><code>/);
assert.match(html, /\$CacheHitRate: 0\.5/);
assert.match(html, /中🙂 `code` &lt;tag&gt;/);
assert.doesNotMatch(html, /class="katex|<em>|<strong>/);
});
12 changes: 11 additions & 1 deletion src/components/chat/view/subcomponents/MessageComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
() => formatUsageLimitText(String(message.content || '')),
[message.content]
);
const assistantCopyContent = message.isToolUse
const assistantCopyContent = message.isLocalCommandStdout
? String(message.content || '')
: message.isToolUse
? String(message.displayText || message.content || '')
: formattedMessageContent;
const isCommandOrFileEditToolResponse = Boolean(
Expand Down Expand Up @@ -439,6 +441,14 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
)}

{(() => {
if (message.isLocalCommandStdout) {
return (
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-sm">
<code>{String(message.content || '')}</code>
</pre>
);
}

const content = formattedMessageContent;

// Detect if content is pure JSON (starts with { or [)
Expand Down