From 5fb8fff1d23c6fc81861d4d71148af141eb8858d Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 1 May 2026 09:16:24 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-assistant/agent/issues/268 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..146f108 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-01T09:16:24.058Z for PR creation at branch issue-268-60db55a2b7be for issue https://github.com/link-assistant/agent/issues/268 \ No newline at end of file From 70dc08c18903cd0e109be2d62d89329333dc032b Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 1 May 2026 09:37:20 +0000 Subject: [PATCH 2/4] Add Claude stream-json stdin support --- js/src/cli/continuous-mode.js | 142 +++++++++++++++++++++------ js/src/cli/input-queue.js | 174 +++++++++++++++++++++++++++++++++- js/src/cli/output.ts | 2 +- js/src/cli/run-options.js | 20 ++++ js/src/index.js | 106 ++++++++++++++++----- js/tests/cli.ts | 30 ++++++ js/tests/cli_options.ts | 39 ++++++++ js/tests/input-queue.js | 101 ++++++++++++++++++++ rust/src/cli.rs | 19 +++- rust/tests/cli.rs | 35 +++++++ rust/tests/cli_options.rs | 55 +++++++++++ 11 files changed, 666 insertions(+), 57 deletions(-) create mode 100644 js/tests/input-queue.js diff --git a/js/src/cli/continuous-mode.js b/js/src/cli/continuous-mode.js index 5c047d9..2e0871a 100644 --- a/js/src/cli/continuous-mode.js +++ b/js/src/cli/continuous-mode.js @@ -8,7 +8,7 @@ import { Instance } from '../project/instance.ts'; import { Bus } from '../bus/index.ts'; import { Session } from '../session/index.ts'; import { SessionPrompt } from '../session/prompt.ts'; -import { createEventHandler } from '../json-standard/index.ts'; +import { createEventHandler, serializeOutput } from '../json-standard/index.ts'; import { createContinuousStdinReader } from './input-queue.js'; import { Log } from '../util/log.ts'; import { config } from '../config/config.ts'; @@ -40,6 +40,64 @@ export function getHasError() { // Logger for resume operations const log = Log.create({ service: 'resume' }); +function getInputFormat(argv) { + return argv['input-format'] || argv.inputFormat || 'text'; +} + +function outputConsumedInput({ + message, + jsonStandard, + sessionID, + compactJson, +}) { + const raw = message.raw || message.message || message.system || ''; + if ( + jsonStandard === 'claude' && + message.kind === 'message' && + message.format === 'stream-json' + ) { + process.stdout.write( + serializeOutput( + { + type: 'message', + timestamp: new Date().toISOString(), + session_id: sessionID, + role: 'user', + content: [{ type: 'text', text: message.message ?? '' }], + }, + 'claude' + ) + ); + return; + } + + if (jsonStandard === 'claude') { + return; + } + + outputInput( + { + raw, + parsed: message.parsed || message, + format: message.format || 'text', + kind: message.kind || 'message', + }, + compactJson + ); +} + +function outputInputParseError(error, line, compactJson) { + hasError = true; + outputError( + { + errorType: 'ValidationError', + message: error instanceof Error ? error.message : String(error), + raw: line, + }, + compactJson + ); +} + /** * Resolve the session to use based on --resume, --continue, and --no-fork options. * Returns the session ID to use, handling forking as needed. @@ -200,8 +258,12 @@ export async function runContinuousServerMode( ) { // Check both CLI flag and environment variable for compact JSON mode const compactJson = argv['compact-json'] === true || config.compactJson; + const inputFormat = getInputFormat(argv); const isInteractive = argv.interactive !== false; - const autoMerge = argv['auto-merge-queued-messages'] !== false; + const autoMerge = + inputFormat === 'stream-json' + ? false + : argv['auto-merge-queued-messages'] !== false; // Start server like OpenCode does const server = Server.listen({ port: 0, hostname: '127.0.0.1' }); @@ -244,9 +306,23 @@ export async function runContinuousServerMode( // Track if we're currently processing a message let isProcessing = false; const pendingMessages = []; + let currentSystemMessage = systemMessage; + const currentAppendSystemMessage = appendSystemMessage; // Process messages from the queue const processMessage = async (message) => { + if (message.kind === 'interrupt') { + SessionPrompt.cancel(sessionID); + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); + return; + } + + if (message.kind === 'system') { + currentSystemMessage = message.system; + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); + return; + } + if (isProcessing) { pendingMessages.push(message); return; @@ -255,16 +331,9 @@ export async function runContinuousServerMode( isProcessing = true; // Output input confirmation in JSON format - outputInput( - { - raw: message.raw || message.message, - parsed: message, - format: message.format || 'text', - }, - compactJson - ); + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); - const messageText = message.message || 'hi'; + const messageText = message.message ?? 'hi'; const parts = [{ type: 'text', text: messageText }]; // Create a promise to wait for this message to complete @@ -290,8 +359,8 @@ export async function runContinuousServerMode( parts, model: { providerID, modelID }, compactionModel, - system: systemMessage, - appendSystem: appendSystemMessage, + system: currentSystemMessage, + appendSystem: currentAppendSystemMessage, temperature, }), } @@ -377,11 +446,15 @@ export async function runContinuousServerMode( // Create continuous stdin reader stdinReader = createContinuousStdinReader({ - interactive: isInteractive, + interactive: inputFormat === 'stream-json' ? false : isInteractive, autoMerge, + inputFormat, onMessage: (message) => { processMessage(message); }, + onError: (error, line) => { + outputInputParseError(error, line, compactJson); + }, }); // Wait for stdin to end (EOF or close) @@ -454,8 +527,12 @@ export async function runContinuousDirectMode( ) { // Check both CLI flag and environment variable for compact JSON mode const compactJson = argv['compact-json'] === true || config.compactJson; + const inputFormat = getInputFormat(argv); const isInteractive = argv.interactive !== false; - const autoMerge = argv['auto-merge-queued-messages'] !== false; + const autoMerge = + inputFormat === 'stream-json' + ? false + : argv['auto-merge-queued-messages'] !== false; let unsub = null; let stdinReader = null; @@ -483,9 +560,23 @@ export async function runContinuousDirectMode( // Track if we're currently processing a message let isProcessing = false; const pendingMessages = []; + let currentSystemMessage = systemMessage; + const currentAppendSystemMessage = appendSystemMessage; // Process messages from the queue const processMessage = async (message) => { + if (message.kind === 'interrupt') { + SessionPrompt.cancel(sessionID); + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); + return; + } + + if (message.kind === 'system') { + currentSystemMessage = message.system; + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); + return; + } + if (isProcessing) { pendingMessages.push(message); return; @@ -494,16 +585,9 @@ export async function runContinuousDirectMode( isProcessing = true; // Output input confirmation in JSON format - outputInput( - { - raw: message.raw || message.message, - parsed: message, - format: message.format || 'text', - }, - compactJson - ); + outputConsumedInput({ message, jsonStandard, sessionID, compactJson }); - const messageText = message.message || 'hi'; + const messageText = message.message ?? 'hi'; const parts = [{ type: 'text', text: messageText }]; // Create a promise to wait for this message to complete @@ -525,8 +609,8 @@ export async function runContinuousDirectMode( parts, model: { providerID, modelID }, compactionModel, - system: systemMessage, - appendSystem: appendSystemMessage, + system: currentSystemMessage, + appendSystem: currentAppendSystemMessage, temperature, }).catch((error) => { hasError = true; @@ -610,11 +694,15 @@ export async function runContinuousDirectMode( // Create continuous stdin reader stdinReader = createContinuousStdinReader({ - interactive: isInteractive, + interactive: inputFormat === 'stream-json' ? false : isInteractive, autoMerge, + inputFormat, onMessage: (message) => { processMessage(message); }, + onError: (error, line) => { + outputInputParseError(error, line, compactJson); + }, }); // Wait for stdin to end (EOF or close) diff --git a/js/src/cli/input-queue.js b/js/src/cli/input-queue.js index 3d9bbcb..caa41bc 100644 --- a/js/src/cli/input-queue.js +++ b/js/src/cli/input-queue.js @@ -5,11 +5,13 @@ export class InputQueue { constructor(options = {}) { this.queue = []; + this.inputFormat = options.inputFormat || 'text'; this.autoMerge = options.autoMerge !== false; // enabled by default this.mergeDelayMs = options.mergeDelayMs || 50; // delay to wait for more lines this.pendingLines = []; this.mergeTimer = null; this.onMessage = options.onMessage || (() => {}); + this.onError = options.onError || (() => {}); this.interactive = options.interactive !== false; // enabled by default } @@ -24,6 +26,10 @@ export class InputQueue { return null; } + if (this.inputFormat === 'stream-json') { + return parseStreamJsonInput(trimmed); + } + try { const parsed = JSON.parse(trimmed); // If it has a message field, use it directly @@ -43,6 +49,11 @@ export class InputQueue { * @param {string} line - Input line */ addLine(line) { + if (this.inputFormat === 'stream-json') { + this.queueLine(line); + return; + } + if (!this.interactive && !line.trim().startsWith('{')) { // In non-interactive mode, only accept JSON return; @@ -54,11 +65,7 @@ export class InputQueue { this.scheduleMerge(); } else { // No merging, queue immediately - const parsed = this.parseInput(line); - if (parsed) { - this.queue.push(parsed); - this.notifyMessage(parsed); - } + this.queueLine(line); } } @@ -130,6 +137,33 @@ export class InputQueue { } } + /** + * Notify listener of an input parse error + * @param {Error} error - Parse error + * @param {string} line - Raw input line + */ + notifyError(error, line) { + if (this.onError) { + this.onError(error, line); + } + } + + /** + * Parse and enqueue one complete input line + * @param {string} line - Raw input line + */ + queueLine(line) { + try { + const parsed = this.parseInput(line); + if (parsed) { + this.queue.push(parsed); + this.notifyMessage(parsed); + } + } catch (error) { + this.notifyError(error, line); + } + } + /** * Get queue size * @returns {number} @@ -139,6 +173,136 @@ export class InputQueue { } } +function extractContentText(content) { + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + const parts = []; + for (const part of content) { + if (typeof part === 'string') { + parts.push(part); + } else if (part && typeof part === 'object') { + if (typeof part.text === 'string') { + parts.push(part.text); + } else if (typeof part.content === 'string') { + parts.push(part.content); + } + } + } + return parts.length > 0 ? parts.join('\n') : null; + } + + if (content && typeof content === 'object') { + if (typeof content.text === 'string') { + return content.text; + } + if ('content' in content) { + return extractContentText(content.content); + } + } + + return null; +} + +function extractFrameText(frame) { + if (typeof frame.message === 'string') { + return frame.message; + } + + if (frame.message && typeof frame.message === 'object') { + const messageText = extractContentText(frame.message.content); + if (messageText !== null) { + return messageText; + } + if (typeof frame.message.text === 'string') { + return frame.message.text; + } + } + + const contentText = extractContentText(frame.content); + if (contentText !== null) { + return contentText; + } + + if (typeof frame.text === 'string') { + return frame.text; + } + + return null; +} + +/** + * Parse one Claude-compatible stream-json input frame. + * @param {string} input - One JSONL frame + * @returns {object} Normalized queue message + */ +export function parseStreamJsonInput(input) { + let frame; + try { + frame = JSON.parse(input); + } catch (error) { + throw new Error( + `Invalid stream-json input frame: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + if (!frame || typeof frame !== 'object' || Array.isArray(frame)) { + throw new Error('Invalid stream-json input frame: expected JSON object'); + } + + const type = frame.type; + + if (type === 'interrupt') { + return { + kind: 'interrupt', + raw: input, + parsed: frame, + format: 'stream-json', + inputType: type, + }; + } + + if (type === 'system') { + const system = extractFrameText(frame); + if (system === null) { + throw new Error( + 'Invalid stream-json system frame: expected content text' + ); + } + return { + kind: 'system', + system, + raw: input, + parsed: frame, + format: 'stream-json', + inputType: type, + }; + } + + if (type === 'user' || type === 'user_prompt' || type === undefined) { + const message = extractFrameText(frame); + if (message === null) { + throw new Error( + 'Invalid stream-json user frame: expected message content text' + ); + } + return { + kind: 'message', + message, + raw: input, + parsed: frame, + format: 'stream-json', + inputType: type || 'message', + }; + } + + throw new Error(`Unsupported stream-json input frame type: ${String(type)}`); +} + /** * Create a continuous stdin reader that queues input lines * @param {object} options - Options for the reader diff --git a/js/src/cli/output.ts b/js/src/cli/output.ts index c569628..55519e5 100644 --- a/js/src/cli/output.ts +++ b/js/src/cli/output.ts @@ -232,7 +232,7 @@ export function outputInput( input: { raw: string; parsed?: unknown; - format?: 'json' | 'text'; + format?: 'json' | 'text' | 'stream-json'; [key: string]: unknown; }, compact?: boolean diff --git a/js/src/cli/run-options.js b/js/src/cli/run-options.js index 4f20bb0..97ac414 100644 --- a/js/src/cli/run-options.js +++ b/js/src/cli/run-options.js @@ -29,6 +29,19 @@ export function buildRunOptions(yargs, defaultOptions = {}) { default: 'opencode', choices: ['opencode', 'claude'], }) + .option('output-format', { + type: 'string', + description: + 'Claude-compatible output format alias: "json" (OpenCode JSON) or "stream-json" (Claude NDJSON)', + choices: ['json', 'stream-json'], + }) + .option('input-format', { + type: 'string', + description: + 'Input format: "text" (default) or Claude-compatible "stream-json" JSONL frames', + default: 'text', + choices: ['text', 'stream-json'], + }) .option('system-message', { type: 'string', description: 'Full override of the system message', @@ -135,6 +148,13 @@ export function buildRunOptions(yargs, defaultOptions = {}) { argv['no-fork'] = true; argv.noFork = true; } + if (argv.outputFormat === 'stream-json') { + argv['json-standard'] = 'claude'; + argv.jsonStandard = 'claude'; + } else if (argv.outputFormat === 'json') { + argv['json-standard'] = 'opencode'; + argv.jsonStandard = 'opencode'; + } }, true) : parser; diff --git a/js/src/index.js b/js/src/index.js index d558a9d..fe867b5 100755 --- a/js/src/index.js +++ b/js/src/index.js @@ -36,6 +36,7 @@ import { runContinuousDirectMode, resolveResumeSession, } from './cli/continuous-mode.js'; +import { parseStreamJsonInput } from './cli/input-queue.js'; import { createBusEventSubscription } from './cli/event-handler.js'; import { outputStatus, @@ -259,6 +260,19 @@ async function readSystemMessages(argv) { return { systemMessage, appendSystemMessage }; } +function getInputFormat(argv) { + return argv['input-format'] || argv.inputFormat || 'text'; +} + +function getAcceptedInputFormats(inputFormat, isInteractive) { + if (inputFormat === 'stream-json') { + return ['Claude stream-json user frames']; + } + return isInteractive + ? ['JSON object with "message" field', 'Plain text'] + : ['JSON object with "message" field']; +} + async function runAgentMode(argv, request) { // Log version and command info in verbose mode using lazy logging Log.Default.info(() => ({ @@ -663,8 +677,15 @@ async function main() { // Check if stdin is a TTY (interactive terminal) if (process.stdin.isTTY) { // Enter interactive terminal mode with continuous listening - const isInteractive = argv.interactive !== false; - const autoMerge = argv['auto-merge-queued-messages'] !== false; + const inputFormat = getInputFormat(argv); + const isInteractive = + inputFormat === 'stream-json' + ? false + : argv.interactive !== false; + const autoMerge = + inputFormat === 'stream-json' + ? false + : argv['auto-merge-queued-messages'] !== false; const alwaysAcceptStdin = argv['always-accept-stdin'] !== false; // Exit if --no-always-accept-stdin is set (single message mode not supported in TTY) @@ -688,10 +709,12 @@ async function main() { message: 'Agent CLI in interactive terminal mode. Type your message and press Enter.', hint: 'Press CTRL+C to exit. Use --help for options.', - acceptedFormats: isInteractive - ? ['JSON object with "message" field', 'Plain text'] - : ['JSON object with "message" field'], + acceptedFormats: getAcceptedInputFormats( + inputFormat, + isInteractive + ), options: { + inputFormat, interactive: isInteractive, autoMergeQueuedMessages: autoMerge, alwaysAcceptStdin, @@ -707,8 +730,13 @@ async function main() { } // stdin is piped - enter stdin listening mode - const isInteractive = argv.interactive !== false; - const autoMerge = argv['auto-merge-queued-messages'] !== false; + const inputFormat = getInputFormat(argv); + const isInteractive = + inputFormat === 'stream-json' ? false : argv.interactive !== false; + const autoMerge = + inputFormat === 'stream-json' + ? false + : argv['auto-merge-queued-messages'] !== false; const alwaysAcceptStdin = argv['always-accept-stdin'] !== false; outputStatus( @@ -719,10 +747,12 @@ async function main() { ? 'Agent CLI in continuous listening mode. Accepts JSON and plain text input.' : 'Agent CLI in single-message mode. Accepts JSON and plain text input.', hint: 'Press CTRL+C to exit. Use --help for options.', - acceptedFormats: isInteractive - ? ['JSON object with "message" field', 'Plain text'] - : ['JSON object with "message" field'], + acceptedFormats: getAcceptedInputFormats( + inputFormat, + isInteractive + ), options: { + inputFormat, interactive: isInteractive, autoMergeQueuedMessages: autoMerge, alwaysAcceptStdin, @@ -757,27 +787,52 @@ async function main() { // Try to parse as JSON, if it fails treat it as plain text message let request; - try { - request = JSON.parse(trimmedInput); - } catch (_e) { - // Not JSON - if (!isInteractive) { - // In non-interactive mode, only accept JSON + if (inputFormat === 'stream-json') { + try { + const firstFrame = trimmedInput + .split(/\r?\n/) + .find((line) => line.trim()); + request = parseStreamJsonInput(firstFrame || ''); + if (request.kind !== 'message') { + throw new Error( + `Expected stream-json user frame, received ${request.kind}` + ); + } + } catch (error) { outputError( { errorType: 'ValidationError', message: - 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.', - hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}', + error instanceof Error ? error.message : String(error), + hint: 'Provide one Claude stream-json user frame per line.', }, compactJson ); process.exit(1); } - // In interactive mode, treat as plain text message - request = { - message: trimmedInput, - }; + } else { + try { + request = JSON.parse(trimmedInput); + } catch (_e) { + // Not JSON + if (!isInteractive) { + // In non-interactive mode, only accept JSON + outputError( + { + errorType: 'ValidationError', + message: + 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.', + hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}', + }, + compactJson + ); + process.exit(1); + } + // In interactive mode, treat as plain text message + request = { + message: trimmedInput, + }; + } } // Output input confirmation in JSON format @@ -785,7 +840,12 @@ async function main() { { raw: trimmedInput, parsed: request, - format: isInteractive ? 'text' : 'json', + format: + inputFormat === 'stream-json' + ? 'stream-json' + : isInteractive + ? 'text' + : 'json', }, compactJson ); diff --git a/js/tests/cli.ts b/js/tests/cli.ts index e1a4539..91129ac 100644 --- a/js/tests/cli.ts +++ b/js/tests/cli.ts @@ -65,6 +65,8 @@ describe('cli', () => { const args = await parseArgs([]); expect(args.model).toBe(DEFAULT_MODEL); expect(args.jsonStandard).toBe('opencode'); + expect(args.inputFormat).toBe('text'); + expect(args.outputFormat).toBeUndefined(); expect(args.server).toBe(true); expect(args.verbose).toBe(false); expect(args.dryRun).toBe(false); @@ -138,6 +140,28 @@ describe('cli', () => { expect(args.jsonStandard).toBe('claude'); }); + test('test_args_input_format_stream_json', async () => { + const args = await parseArgs(['--input-format', 'stream-json']); + expect(args.inputFormat).toBe('stream-json'); + }); + + test('test_args_output_format_stream_json_maps_to_claude', async () => { + const args = await parseArgs(['--output-format', 'stream-json']); + expect(args.outputFormat).toBe('stream-json'); + expect(args.jsonStandard).toBe('claude'); + }); + + test('test_args_output_format_json_maps_to_opencode', async () => { + const args = await parseArgs([ + '--json-standard', + 'claude', + '--output-format', + 'json', + ]); + expect(args.outputFormat).toBe('json'); + expect(args.jsonStandard).toBe('opencode'); + }); + test('test_args_system_message', async () => { const args = await parseArgs(['--system-message', 'You are helpful']); expect(args.systemMessage).toBe('You are helpful'); @@ -293,6 +317,10 @@ describe('cli', () => { 'opencode/gpt-5', '--json-standard', 'claude', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', '--system-message', 'Be helpful', '--verbose', @@ -317,6 +345,8 @@ describe('cli', () => { expect(args.model).toBe('opencode/gpt-5'); expect(args.jsonStandard).toBe('claude'); + expect(args.inputFormat).toBe('stream-json'); + expect(args.outputFormat).toBe('stream-json'); expect(args.systemMessage).toBe('Be helpful'); expect(args.verbose).toBe(true); expect(args.dryRun).toBe(true); diff --git a/js/tests/cli_options.ts b/js/tests/cli_options.ts index 8e69b34..e2150a2 100644 --- a/js/tests/cli_options.ts +++ b/js/tests/cli_options.ts @@ -80,6 +80,39 @@ describe('cli_options', () => { expect(argv.jsonStandard).toBe('claude'); }); + test('input_format_default', async () => { + const argv = await parseRunOptions([]); + expect(argv.inputFormat).toBe('text'); + }); + + test('input_format_stream_json', async () => { + const argv = await parseRunOptions(['--input-format', 'stream-json']); + expect(argv.inputFormat).toBe('stream-json'); + }); + + test('input_format_rejects_invalid', async () => { + await expect(parseRunOptions(['--input-format', 'json'])).rejects.toThrow( + 'Invalid values' + ); + }); + + test('output_format_stream_json_maps_to_claude', async () => { + const argv = await parseRunOptions(['--output-format', 'stream-json']); + expect(argv.outputFormat).toBe('stream-json'); + expect(argv.jsonStandard).toBe('claude'); + }); + + test('output_format_json_maps_to_opencode', async () => { + const argv = await parseRunOptions([ + '--json-standard', + 'claude', + '--output-format', + 'json', + ]); + expect(argv.outputFormat).toBe('json'); + expect(argv.jsonStandard).toBe('opencode'); + }); + test('json_standard_rejects_invalid', async () => { await expect(parseRunOptions(['--json-standard', 'xml'])).rejects.toThrow( 'Invalid values' @@ -373,6 +406,10 @@ describe('cli_options', () => { 'opencode/gpt-5', '--json-standard', 'claude', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', '--system-message', 'Be helpful', '--verbose', @@ -409,6 +446,8 @@ describe('cli_options', () => { expect(argv.model).toBe('opencode/gpt-5'); expect(argv.jsonStandard).toBe('claude'); + expect(argv.inputFormat).toBe('stream-json'); + expect(argv.outputFormat).toBe('stream-json'); expect(argv.systemMessage).toBe('Be helpful'); expect(argv.verbose).toBe(true); expect(argv.dryRun).toBe(true); diff --git a/js/tests/input-queue.js b/js/tests/input-queue.js new file mode 100644 index 0000000..98085f3 --- /dev/null +++ b/js/tests/input-queue.js @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import { InputQueue, parseStreamJsonInput } from '../src/cli/input-queue.js'; + +describe('InputQueue stream-json input', () => { + test('queues each Claude stream-json frame as a separate user message', () => { + const messages = []; + const queue = new InputQueue({ + inputFormat: 'stream-json', + autoMerge: true, + onMessage: (message) => messages.push(message), + }); + + queue.addLine( + JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'first prompt' }], + }, + }) + ); + queue.addLine( + JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'second prompt' }], + }, + }) + ); + + expect(queue.size()).toBe(2); + expect(messages.map((message) => message.message)).toEqual([ + 'first prompt', + 'second prompt', + ]); + expect(messages.every((message) => message.format === 'stream-json')).toBe( + true + ); + }); + + test('supports simplified user_prompt frames from the issue contract', () => { + const queue = new InputQueue({ + inputFormat: 'stream-json', + autoMerge: true, + }); + + queue.addLine( + JSON.stringify({ + type: 'user_prompt', + content: 'hello from user_prompt', + }) + ); + + expect(queue.dequeue()).toMatchObject({ + kind: 'message', + message: 'hello from user_prompt', + format: 'stream-json', + }); + }); + + test('normalizes system and interrupt frames', () => { + expect( + parseStreamJsonInput( + JSON.stringify({ + type: 'system', + content: 'use short answers', + }) + ) + ).toMatchObject({ + kind: 'system', + system: 'use short answers', + format: 'stream-json', + }); + + expect( + parseStreamJsonInput( + JSON.stringify({ + type: 'interrupt', + }) + ) + ).toMatchObject({ + kind: 'interrupt', + format: 'stream-json', + }); + }); + + test('rejects stream-json user frames without text content', () => { + expect(() => + parseStreamJsonInput( + JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'image', source: 'ignored' }], + }, + }) + ) + ).toThrow('expected message content text'); + }); +}); diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 228e1f8..2f66fc6 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -30,6 +30,14 @@ pub struct Args { #[arg(long, default_value = "opencode", value_parser = ["opencode", "claude"])] pub json_standard: String, + /// Claude-compatible output format alias: "json" or "stream-json" + #[arg(long, value_parser = ["json", "stream-json"])] + pub output_format: Option, + + /// Input format: "text" (default) or Claude-compatible "stream-json" JSONL frames + #[arg(long, default_value = "text", value_parser = ["text", "stream-json"])] + pub input_format: String, + /// Full override of the system message #[arg(long)] pub system_message: Option, @@ -176,6 +184,15 @@ impl Args { !self.no_server } + /// Effective JSON output standard after applying --output-format aliases. + pub fn effective_json_standard(&self) -> &str { + match self.output_format.as_deref() { + Some("stream-json") => "claude", + Some("json") => "opencode", + _ => self.json_standard.as_str(), + } + } + /// Effective auto-merge: defaults to true, --no-auto-merge-queued-messages sets to false pub fn auto_merge_queued_messages(&self) -> bool { !self.no_auto_merge_queued_messages @@ -495,7 +512,7 @@ async fn run_with_input( &OutputEvent::Text { timestamp: timestamp_ms(), session_id: session_id.clone(), - text: format!("JSON standard: {}", args.json_standard), + text: format!("JSON standard: {}", args.effective_json_standard()), }, args.compact_json, ); diff --git a/rust/tests/cli.rs b/rust/tests/cli.rs index b4db88e..48f59d4 100644 --- a/rust/tests/cli.rs +++ b/rust/tests/cli.rs @@ -26,6 +26,8 @@ fn test_args_defaults() { let args = Args::parse_from(["agent"]); assert_eq!(args.model, DEFAULT_MODEL); assert_eq!(args.json_standard, "opencode"); + assert_eq!(args.input_format, "text"); + assert!(args.output_format.is_none()); assert!(args.server()); assert!(!args.verbose); assert!(!args.dry_run); @@ -108,6 +110,32 @@ fn test_args_json_standard_claude() { assert_eq!(args.json_standard, "claude"); } +#[test] +fn test_args_input_format_stream_json() { + let args = Args::parse_from(["agent", "--input-format", "stream-json"]); + assert_eq!(args.input_format, "stream-json"); +} + +#[test] +fn test_args_output_format_stream_json() { + let args = Args::parse_from(["agent", "--output-format", "stream-json"]); + assert_eq!(args.output_format, Some("stream-json".to_string())); + assert_eq!(args.effective_json_standard(), "claude"); +} + +#[test] +fn test_args_output_format_json_maps_to_opencode() { + let args = Args::parse_from([ + "agent", + "--json-standard", + "claude", + "--output-format", + "json", + ]); + assert_eq!(args.output_format, Some("json".to_string())); + assert_eq!(args.effective_json_standard(), "opencode"); +} + #[test] fn test_args_system_message() { let args = Args::parse_from(["agent", "--system-message", "You are helpful"]); @@ -290,6 +318,10 @@ fn test_args_all_options_combined() { "opencode/gpt-5", "--json-standard", "claude", + "--input-format", + "stream-json", + "--output-format", + "stream-json", "--system-message", "Be helpful", "--verbose", @@ -313,6 +345,9 @@ fn test_args_all_options_combined() { ]); assert_eq!(args.model, "opencode/gpt-5"); assert_eq!(args.json_standard, "claude"); + assert_eq!(args.input_format, "stream-json"); + assert_eq!(args.output_format, Some("stream-json".to_string())); + assert_eq!(args.effective_json_standard(), "claude"); assert_eq!(args.system_message, Some("Be helpful".to_string())); assert!(args.verbose); assert!(args.dry_run); diff --git a/rust/tests/cli_options.rs b/rust/tests/cli_options.rs index ce4d2a1..3d2a384 100644 --- a/rust/tests/cli_options.rs +++ b/rust/tests/cli_options.rs @@ -111,6 +111,57 @@ fn json_standard_rejects_invalid() { .stderr(predicate::str::contains("invalid value")); } +#[test] +fn input_format_stream_json_accepted() { + agent_cmd() + .args(["--dry-run", "--input-format", "stream-json", "-p", "hello"]) + .assert() + .success(); +} + +#[test] +fn input_format_rejects_invalid() { + agent_cmd() + .args(["--dry-run", "--input-format", "json", "-p", "hello"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn output_format_stream_json_maps_to_claude() { + agent_cmd() + .args([ + "--dry-run", + "--verbose", + "--output-format", + "stream-json", + "-p", + "hello", + ]) + .assert() + .success() + .stdout(predicate::str::contains("JSON standard: claude")); +} + +#[test] +fn output_format_json_maps_to_opencode() { + agent_cmd() + .args([ + "--dry-run", + "--verbose", + "--json-standard", + "claude", + "--output-format", + "json", + "-p", + "hello", + ]) + .assert() + .success() + .stdout(predicate::str::contains("JSON standard: opencode")); +} + // ── System message options ─────────────────────────────────────────── #[test] @@ -732,6 +783,10 @@ fn all_options_accepted_together() { "opencode/gpt-5", "--json-standard", "claude", + "--input-format", + "stream-json", + "--output-format", + "stream-json", "--system-message", "Be helpful", "--verbose", From 3627542527586faed5314dfd9e127249aea4f98a Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 1 May 2026 09:38:50 +0000 Subject: [PATCH 3/4] Add release notes for stream-json input --- js/.changeset/stream-json-input.md | 5 +++++ rust/changelog.d/20260501_stream_json_input.md | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 js/.changeset/stream-json-input.md create mode 100644 rust/changelog.d/20260501_stream_json_input.md diff --git a/js/.changeset/stream-json-input.md b/js/.changeset/stream-json-input.md new file mode 100644 index 0000000..2724436 --- /dev/null +++ b/js/.changeset/stream-json-input.md @@ -0,0 +1,5 @@ +--- +'@link-assistant/agent': minor +--- + +Add Claude-compatible `--input-format stream-json` stdin frames and `--output-format stream-json` alias with user-prompt replay acknowledgements. diff --git a/rust/changelog.d/20260501_stream_json_input.md b/rust/changelog.d/20260501_stream_json_input.md new file mode 100644 index 0000000..b70a020 --- /dev/null +++ b/rust/changelog.d/20260501_stream_json_input.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- Added Rust CLI option parity for `--input-format stream-json` and `--output-format stream-json`. From 8a2ed40c328539fca906cb477785ce44a7956d9b Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 1 May 2026 09:44:54 +0000 Subject: [PATCH 4/4] Revert "Initial commit with task details" This reverts commit 5fb8fff1d23c6fc81861d4d71148af141eb8858d. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 146f108..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-01T09:16:24.058Z for PR creation at branch issue-268-60db55a2b7be for issue https://github.com/link-assistant/agent/issues/268 \ No newline at end of file