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
2 changes: 2 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ module.exports = {
// - electron/mcp/validation.ts — string-only branch/UUID validators; the renderer
// shares them so it never offers a branch the IPC layer would reject
// - electron/shared/ — renderer-safe constants/pure helpers (no Node/Electron imports)
// - electron/documents/shared.ts — agent ids/labels the document workspace shows
pathNot: [
'^electron/ipc/channels\\.ts',
'^electron/mcp/prompt-detect\\.ts',
'^electron/mcp/validation\\.ts',
'^electron/agent-hooks/status\\.ts',
'^electron/shared/',
'^electron/documents/shared\\.ts',
],
},
},
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

# Runners have no global Git identity, and tests that let the app create a
# repository of its own commit into it without a local one to fall back on.
- name: Give Git an identity
run: |
git config --global user.email "ci@example.com"
git config --global user.name "CI"

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ update-test/
# Keep generated docs content ignored, but track the authored architecture overview.
docs/*
!docs/architecture-overview.html
!docs/document-workspaces.md

# Sandbox bind-mount artifacts from user home (not project files).
# Root-anchored so legitimate nested files with these names are still tracked.
Expand Down
360 changes: 360 additions & 0 deletions docs/document-workspaces.md

Large diffs are not rendered by default.

244 changes: 244 additions & 0 deletions electron/documents/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { describe, expect, it } from 'vitest';
import { buildHeadlessLaunch, createHeadlessParser, CLAUDE_DOCUMENT_TOOLS } from './agents.js';

describe('buildHeadlessLaunch', () => {
it('starts a fresh claude session with a fixed id and no shell tools', () => {
const launch = buildHeadlessLaunch({
agentId: 'claude-code',
command: 'claude',
prompt: 'Do it',
newSessionId: 'sid-1',
});
expect(launch.command).toBe('claude');
expect(launch.args).toContain('--session-id');
expect(launch.args).toContain('sid-1');
expect(launch.args).not.toContain('--resume');
const toolsIdx = launch.args.indexOf('--tools');
expect(launch.args[toolsIdx + 1]).toBe(CLAUDE_DOCUMENT_TOOLS);
expect(launch.args).not.toContain('--dangerously-skip-permissions');
});

it('resumes claude by session id', () => {
const launch = buildHeadlessLaunch({
agentId: 'claude-code',
command: 'claude',
prompt: 'Again',
sessionId: 'old',
newSessionId: 'new',
});
expect(launch.args).toContain('--resume');
expect(launch.args).toContain('old');
expect(launch.args).not.toContain('--session-id');
});

it('puts codex flags before the resume subcommand', () => {
const launch = buildHeadlessLaunch({
agentId: 'codex',
command: 'codex',
prompt: 'P',
sessionId: 'thread-1',
newSessionId: 'x',
});
expect(launch.args.slice(0, 2)).toEqual(['exec', '--json']);
expect(launch.args.indexOf('resume')).toBeGreaterThan(launch.args.indexOf('--sandbox'));
expect(launch.args.slice(-2)).toEqual(['thread-1', 'P']);
});

it('ignores a session id for agents that cannot resume', () => {
const launch = buildHeadlessLaunch({
agentId: 'gemini',
command: 'gemini',
prompt: 'P',
sessionId: 'ignored',
newSessionId: 'x',
});
expect(launch.args).not.toContain('ignored');
});

it('starts with the CLI defaults when no model or effort is chosen', () => {
for (const agentId of ['claude-code', 'codex', 'gemini']) {
const launch = buildHeadlessLaunch({ agentId, command: 'x', prompt: 'P', newSessionId: 's' });
expect(launch.args).not.toContain('--model');
expect(launch.args).not.toContain('--effort');
expect(launch.args).not.toContain('-c');
}
});

it('passes model and effort to claude', () => {
const launch = buildHeadlessLaunch({
agentId: 'claude-code',
command: 'claude',
prompt: 'P',
newSessionId: 's',
model: 'opus',
effort: 'high',
});
expect(launch.args.indexOf('--model')).toBeGreaterThan(-1);
expect(launch.args[launch.args.indexOf('--model') + 1]).toBe('opus');
expect(launch.args[launch.args.indexOf('--effort') + 1]).toBe('high');
});

it('gives codex its model flag and effort override before the resume subcommand', () => {
const launch = buildHeadlessLaunch({
agentId: 'codex',
command: 'codex',
prompt: 'P',
sessionId: 'thread-1',
newSessionId: 's',
model: 'gpt-5-codex',
effort: 'xhigh',
});
const resumeAt = launch.args.indexOf('resume');
expect(launch.args.indexOf('--model')).toBeLessThan(resumeAt);
expect(launch.args[launch.args.indexOf('--model') + 1]).toBe('gpt-5-codex');
const configAt = launch.args.indexOf('-c');
expect(configAt).toBeLessThan(resumeAt);
expect(launch.args[configAt + 1]).toBe('model_reasoning_effort="xhigh"');
expect(launch.args.slice(-2)).toEqual(['thread-1', 'P']);
});

it('passes a gemini model and drops the effort it has no flag for', () => {
const launch = buildHeadlessLaunch({
agentId: 'gemini',
command: 'gemini',
prompt: 'P',
newSessionId: 's',
model: 'gemini-2.5-pro',
effort: 'high',
});
expect(launch.args[launch.args.indexOf('--model') + 1]).toBe('gemini-2.5-pro');
expect(launch.args).not.toContain('high');
});

it('rejects agents without a headless mode', () => {
expect(() =>
buildHeadlessLaunch({
agentId: 'antigravity',
command: 'agy',
prompt: 'P',
newSessionId: 'x',
}),
).toThrow(/no headless mode/);
});
});

describe('claude stream-json parser', () => {
it('collects session id, tool log lines and the final result across chunks', () => {
const parser = createHeadlessParser('claude-code');
const lines = [
JSON.stringify({ type: 'system', subtype: 'init', session_id: 's1' }),
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'Looking' },
{ type: 'tool_use', name: 'Edit', input: { file_path: 'docs/a.md' } },
],
},
}),
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'Final ```json\n{}\n```',
session_id: 's1',
}),
];
const joined = lines.join('\n') + '\n';
const log = [...parser.feed(joined.slice(0, 40)), ...parser.feed(joined.slice(40))];
expect(log).toEqual(['session started', 'Looking', '→ Edit docs/a.md']);
const outcome = parser.finish();
expect(outcome.sessionId).toBe('s1');
expect(outcome.resultText).toContain('Final');
expect(outcome.error).toBeUndefined();
});

it('surfaces an error result', () => {
const parser = createHeadlessParser('claude-code');
parser.feed(
JSON.stringify({ type: 'result', subtype: 'error', is_error: true, result: 'boom' }) + '\n',
);
expect(parser.finish().error).toBe('boom');
});

it('names the failure from errors or the subtype when the result is empty', () => {
const detailed = createHeadlessParser('claude-code');
detailed.feed(
JSON.stringify({
type: 'result',
subtype: 'error_during_execution',
is_error: true,
errors: ['rate limited'],
}) + '\n',
);
expect(detailed.finish().error).toBe('rate limited');

const bare = createHeadlessParser('claude-code');
bare.feed(
JSON.stringify({ type: 'result', subtype: 'error_max_turns', is_error: true }) + '\n',
);
expect(bare.finish().error).toBe('max turns');

const blank = createHeadlessParser('claude-code');
blank.feed(JSON.stringify({ type: 'result', subtype: 'error', is_error: true }) + '\n');
expect(blank.finish().error).toBe('agent reported an error');
});

it('passes non-json lines through and falls back to assistant text', () => {
const parser = createHeadlessParser('claude-code');
const log = parser.feed('warning: something\n');
expect(log).toEqual(['warning: something']);
parser.feed(
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'T' }] } }),
);
expect(parser.finish().resultText).toBe('T');
});
});

describe('codex jsonl parser', () => {
it('reads the thread id, file changes and last agent message', () => {
const parser = createHeadlessParser('codex');
const events = [
{ type: 'thread.started', thread_id: 't1' },
{ type: 'item.completed', item: { type: 'agent_message', text: 'first' } },
{
type: 'item.completed',
item: { type: 'file_change', changes: [{ path: 'a.md', kind: 'update' }] },
},
{ type: 'item.completed', item: { type: 'agent_message', text: 'last' } },
{ type: 'turn.completed', usage: {} },
];
const log = parser.feed(events.map((e) => JSON.stringify(e)).join('\n'));
expect(log).toEqual(['session started', 'first', '→ update a.md', 'last']);
const outcome = parser.finish();
expect(outcome.sessionId).toBe('t1');
expect(outcome.resultText).toBe('last');
});

it('records stream errors', () => {
const parser = createHeadlessParser('codex');
parser.feed(JSON.stringify({ type: 'error', message: 'quota' }) + '\n');
expect(parser.finish().error).toBe('quota');
});
});

describe('gemini and plain parsers', () => {
it('extracts the gemini response', () => {
const parser = createHeadlessParser('gemini');
parser.feed('{"response": "hello", ');
parser.feed('"session_id": "g1"}');
expect(parser.finish()).toEqual({ resultText: 'hello', sessionId: 'g1', error: undefined });
});

it('treats unparseable gemini output as text', () => {
const parser = createHeadlessParser('gemini');
parser.feed('plain');
expect(parser.finish().resultText).toBe('plain');
});

it('keeps everything for plain-text agents', () => {
const parser = createHeadlessParser('custom-agent');
const log = parser.feed('one\ntwo\n');
expect(log).toEqual(['one', 'two']);
expect(parser.finish().resultText).toBe('one\ntwo\n');
});
});
Loading
Loading