mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: unify CLI output handling and enhance theme variables
- Updated `CliStreamMonitorNew`, `CliStreamMonitorLegacy`, and `CliViewerPage` components to prioritize `unitContent` from payloads, falling back to `data` when necessary. - Enhanced `colorGenerator` to include legacy variables for compatibility with shadcn/ui. - Refactored orchestrator index to unify node exports under a single module. - Improved `appStore` to clear both new and legacy CSS variables when applying themes. - Added new options to CLI execution for raw and final output modes, improving programmatic output handling. - Enhanced `cli-output-converter` to normalize cumulative delta frames and avoid duplication in streaming outputs. - Introduced a new unified workflow specification for prompt template-based workflows, replacing the previous multi-type node system. - Added tests for CLI final output handling and streaming output converter to ensure correct behavior in various scenarios.
This commit is contained in:
76
ccw/tests/cli-final-only-output.test.js
Normal file
76
ccw/tests/cli-final-only-output.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* ccw cli exec --final output mode
|
||||
*
|
||||
* Ensures programmatic callers can get a clean final agent result without
|
||||
* banners/spinner/summary noise on stdout.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
|
||||
const cliCommandPath = new URL('../dist/commands/cli.js', import.meta.url).href;
|
||||
const cliExecutorPath = new URL('../dist/tools/cli-executor.js', import.meta.url).href;
|
||||
|
||||
function stubHttpRequest() {
|
||||
mock.method(http, 'request', () => {
|
||||
const req = {
|
||||
on(event, handler) {
|
||||
if (event === 'socket') handler({ unref() {} });
|
||||
return req;
|
||||
},
|
||||
write() {},
|
||||
end() {},
|
||||
destroy() {},
|
||||
};
|
||||
return req;
|
||||
});
|
||||
}
|
||||
|
||||
describe('ccw cli exec --final', async () => {
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
it('writes only finalOutput to stdout (no banner/summary)', async () => {
|
||||
stubHttpRequest();
|
||||
|
||||
const cliModule = await import(cliCommandPath);
|
||||
const cliExecutorModule = await import(cliExecutorPath);
|
||||
|
||||
const stdoutWrites = [];
|
||||
mock.method(process.stdout, 'write', (chunk) => {
|
||||
stdoutWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
mock.method(console, 'log', () => {});
|
||||
mock.method(console, 'error', () => {});
|
||||
|
||||
mock.method(cliExecutorModule.cliExecutorTool, 'execute', async () => {
|
||||
return {
|
||||
success: true,
|
||||
stdout: 'STDOUT_SHOULD_NOT_WIN',
|
||||
stderr: '',
|
||||
parsedOutput: 'PARSED_SHOULD_NOT_WIN',
|
||||
finalOutput: 'FINAL',
|
||||
execution: { id: 'EXEC-FINAL', duration_ms: 1, status: 'success' },
|
||||
conversation: { turn_count: 1, total_duration_ms: 1 },
|
||||
};
|
||||
});
|
||||
|
||||
// Prevent the command from terminating the test runner.
|
||||
mock.method(process, 'exit', () => {});
|
||||
|
||||
// Ensure the CLI's internal delayed exit timer doesn't keep the test process alive.
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
mock.method(globalThis, 'setTimeout', (fn, ms, ...args) => {
|
||||
const t = realSetTimeout(fn, ms, ...args);
|
||||
t?.unref?.();
|
||||
return t;
|
||||
});
|
||||
|
||||
await cliModule.cliCommand('exec', [], { prompt: 'Hello', tool: 'gemini', final: true });
|
||||
|
||||
assert.equal(stdoutWrites.join(''), 'FINAL');
|
||||
});
|
||||
});
|
||||
66
ccw/tests/cli-output-converter.test.js
Normal file
66
ccw/tests/cli-output-converter.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* CLI Output Converter - Streaming/Final de-duplication tests
|
||||
*
|
||||
* Runs against the shipped runtime in `ccw/dist`.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createOutputParser, flattenOutputUnits } from '../dist/tools/cli-output-converter.js';
|
||||
|
||||
describe('cli-output-converter (streaming de-dup)', () => {
|
||||
it('normalizes cumulative Gemini delta frames into suffix deltas', () => {
|
||||
const parser = createOutputParser('json-lines');
|
||||
const ts0 = '2026-02-04T00:00:00.000Z';
|
||||
const ts1 = '2026-02-04T00:00:01.000Z';
|
||||
|
||||
const input = [
|
||||
JSON.stringify({ type: 'message', timestamp: ts0, role: 'assistant', content: 'Hello', delta: true }),
|
||||
JSON.stringify({ type: 'message', timestamp: ts1, role: 'assistant', content: 'Hello world', delta: true }),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const units = parser.parse(Buffer.from(input, 'utf8'), 'stdout');
|
||||
|
||||
assert.equal(units.length, 2);
|
||||
assert.equal(units[0].type, 'streaming_content');
|
||||
assert.equal(units[0].content, 'Hello');
|
||||
assert.equal(units[1].type, 'streaming_content');
|
||||
assert.equal(units[1].content, ' world');
|
||||
});
|
||||
|
||||
it('skips non-delta final assistant frame after deltas (avoids stream duplication)', () => {
|
||||
const parser = createOutputParser('json-lines');
|
||||
const ts0 = '2026-02-04T00:00:00.000Z';
|
||||
const ts1 = '2026-02-04T00:00:01.000Z';
|
||||
const ts2 = '2026-02-04T00:00:02.000Z';
|
||||
|
||||
const input = [
|
||||
JSON.stringify({ type: 'message', timestamp: ts0, role: 'assistant', content: 'Hello', delta: true }),
|
||||
JSON.stringify({ type: 'message', timestamp: ts1, role: 'assistant', content: ' world', delta: true }),
|
||||
// Some CLIs send a final non-delta message repeating the full content
|
||||
JSON.stringify({ type: 'message', timestamp: ts2, role: 'assistant', content: 'Hello world', delta: false }),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const units = parser.parse(Buffer.from(input, 'utf8'), 'stdout');
|
||||
assert.equal(units.some((u) => u.type === 'agent_message'), false);
|
||||
assert.equal(units.filter((u) => u.type === 'streaming_content').length, 2);
|
||||
|
||||
const reconstructed = flattenOutputUnits(units, { includeTypes: ['agent_message'] });
|
||||
assert.equal(reconstructed, 'Hello world');
|
||||
});
|
||||
|
||||
it('does not synthesize an extra agent_message when one already exists', () => {
|
||||
const units = [
|
||||
{ type: 'streaming_content', content: 'a', timestamp: '2026-02-04T00:00:00.000Z' },
|
||||
{ type: 'streaming_content', content: 'b', timestamp: '2026-02-04T00:00:01.000Z' },
|
||||
{ type: 'agent_message', content: 'ab', timestamp: '2026-02-04T00:00:02.000Z' },
|
||||
];
|
||||
|
||||
const out = flattenOutputUnits(units, { includeTypes: ['agent_message'] });
|
||||
assert.equal(out, 'ab');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user