diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index 37e3f86..bc5a3a1 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -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[number]; @@ -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 diff --git a/server/gjc-bun-sdk-events.ts b/server/gjc-bun-sdk-events.ts index c05e863..3325ec6 100644 --- a/server/gjc-bun-sdk-events.ts +++ b/server/gjc-bun-sdk-events.ts @@ -1,3 +1,5 @@ +import { stripVTControlCharacters } from 'node:util'; + import type { GjcSessionSnapshot } from './gjc-session-state.js'; import type { GjcWorkerWriter } from './gjc-worker.js'; @@ -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 diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 03c8eca..b3dd07b 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -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', @@ -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` \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).message as Record)) + .filter((message) => message.isLocalCommandStdout === true); + assert.deepEqual(outputs, [ + { + kind: 'text', + role: 'assistant', + content: '$5\t中🙂 `code` \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. * diff --git a/src/components/chat/view/subcomponents/MessageComponent.test.tsx b/src/components/chat/view/subcomponents/MessageComponent.test.tsx index d75eacf..9d55243 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.test.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.test.tsx @@ -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` ', + timestamp: '2026-08-13T00:00:00.000Z', + isLocalCommandStdout: true, + }); + + assert.match(html, /]*>/); + assert.match(html, /\$CacheHitRate: 0\.5/); + assert.match(html, /中🙂 `code` <tag>/); + assert.doesNotMatch(html, /class="katex||/); +}); diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 52c4248..f5924e1 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -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( @@ -439,6 +441,14 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s )} {(() => { + if (message.isLocalCommandStdout) { + return ( +
+                        {String(message.content || '')}
+                      
+ ); + } + const content = formattedMessageContent; // Detect if content is pure JSON (starts with { or [)