Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
04963e2
Merge pull request #2 from JeiKeiLim/feat/document-lifecycle
JeiKeiLim Jun 17, 2026
ec71dec
chore: bump to 26.6.1
JeiKeiLim Jun 17, 2026
225f3bc
feat(cli): consent gate for legacy migration + star-repo nudge
JeiKeiLim Jun 19, 2026
bf2334c
chore: bump to 26.6.2
JeiKeiLim Jun 19, 2026
f676759
Merge pull request #3 from JeiKeiLim/feat/upgrade-consent-star-nudge
JeiKeiLim Jun 19, 2026
63bbbac
feat: enforce design-components in bootstrap/visual phases + detect g…
JeiKeiLim Jun 22, 2026
c1d28ce
chore(ci): add eslint harness and enforce lint in CI
JeiKeiLim Jun 22, 2026
59009c1
docs(claude): fix publish.yml lint drift; document ci.yml + init git-…
JeiKeiLim Jun 22, 2026
c01df69
Merge pull request #4 from JeiKeiLim/feat/git-safety-lint-ci
JeiKeiLim Jun 22, 2026
1f554a2
chore: bump to 26.6.3
JeiKeiLim Jun 22, 2026
f933853
feat(steer): self-maintaining inbox + user/agent separation
JeiKeiLim Jun 22, 2026
036cb52
Merge pull request #5 from JeiKeiLim/feat/steer-inbox-lifecycle
JeiKeiLim Jun 22, 2026
1ead682
feat(doctrine): run-end drift review + durable proposals (no new tool)
JeiKeiLim Jun 23, 2026
bafaa4b
Merge pull request #6 from JeiKeiLim/feat/doctrine-drift-lifecycle
JeiKeiLim Jun 23, 2026
3e22172
docs: align authoritative docs and skills with current code
JeiKeiLim Jun 23, 2026
630d901
Merge pull request #7 from JeiKeiLim/docs/review-consistency-fixes
JeiKeiLim Jun 23, 2026
b70ea3b
chore: bump to 26.6.4
JeiKeiLim Jun 23, 2026
401bdfa
feat(eval): configurable critic roster via .tenet/critics.json
JeiKeiLim Jun 24, 2026
df76d03
docs(readme): document .tenet/critics.json setup + fix stale eval too…
JeiKeiLim Jun 24, 2026
9c7b1b5
Merge pull request #8 from JeiKeiLim/feat/configurable-critics
JeiKeiLim Jun 24, 2026
7ace216
chore: bump to 26.6.5
JeiKeiLim Jun 25, 2026
4e743fa
feat(eval): interaction-e2e critic gets CLI/API/library exploratory p…
JeiKeiLim Jun 25, 2026
cdff826
Merge pull request #9 from JeiKeiLim/feat/interaction-e2e-cli-parity
JeiKeiLim Jun 25, 2026
86e3a68
refactor(eval): rename playwright_eval -> interaction_e2e (eradicate)
JeiKeiLim Jun 25, 2026
02e2b58
Merge pull request #10 from JeiKeiLim/feat/rename-playwright-eval
JeiKeiLim Jun 25, 2026
ec82167
chore: bump to 26.6.6
JeiKeiLim Jun 25, 2026
0ef0a1f
fix(init): rewrite legacy playwright_eval id in critics.json on upgrade
JeiKeiLim Jun 25, 2026
b892572
Merge pull request #11 from JeiKeiLim/fix/critics-json-rename-upgrade
JeiKeiLim Jun 25, 2026
4b304b5
fix(loop): reliable doctrine drift, job visibility, critic context-limit
JeiKeiLim Jun 26, 2026
5cab809
docs: apply doc/code consistency review r1 fixes
JeiKeiLim Jun 26, 2026
3426512
docs: add db command to README architecture summary (review r2)
JeiKeiLim Jun 26, 2026
b6e36ea
Merge pull request #12 from JeiKeiLim/fix/loop-reliability
JeiKeiLim Jun 26, 2026
79ed8e8
chore: bump to 26.6.7
JeiKeiLim Jun 26, 2026
d9d991d
feat(cli): sort tenet status + job-queue by dag_id plan order
JeiKeiLim Jun 29, 2026
f9a460b
feat(cli): gzip db snapshots by default with auto-detecting restore
JeiKeiLim Jun 29, 2026
c57494c
chore: bump to 26.6.8
JeiKeiLim Jun 29, 2026
cee03f5
docs(planning): model tier & worker context design (doc 18)
JeiKeiLim Jul 2, 2026
b910117
fix(worker): inline run docs into worker dispatch context (U1)
JeiKeiLim Jul 2, 2026
bce6605
feat(compile-context): reframe as orchestrator aid + fix worker-deliv…
JeiKeiLim Jul 2, 2026
65e1004
feat(model-tier): prompt-only executioner tier for decomposition gran…
JeiKeiLim Jul 2, 2026
1e261ba
feat(spec): strengthen spec research accuracy + readiness substance g…
JeiKeiLim Jul 2, 2026
8273e40
feat(skill): mode selection is a user-facing checkpoint; Quick no lon…
JeiKeiLim Jul 2, 2026
2975e33
feat(eval): give critics the same inlined run docs as dev workers
JeiKeiLim Jul 2, 2026
7d39d86
feat(eval): per-critic full_context option (grounded vs ungrounded re…
JeiKeiLim Jul 2, 2026
f707958
docs(critics): full_context applies to built-ins too + backward-compa…
JeiKeiLim Jul 2, 2026
39e870e
feat(init): surface full_context in the scaffolded critics.json + can…
JeiKeiLim Jul 2, 2026
c2bab20
feat(init): migrate pre-existing critics.json to add full_context on …
JeiKeiLim Jul 2, 2026
87bd6e0
feat(eval): interaction_e2e defaults to ungrounded (it acts like a user)
JeiKeiLim Jul 2, 2026
1cc3930
Merge pull request #13 from JeiKeiLim/feat/model-tier-and-worker-context
JeiKeiLim Jul 2, 2026
9ff5f21
chore: bump to 26.7.0
JeiKeiLim Jul 2, 2026
5ef8958
fix(eval): stop critics being misled by their run context
JeiKeiLim Jul 2, 2026
7f17b6a
Merge pull request #14 from JeiKeiLim/fix/critic-context-role-leak
JeiKeiLim Jul 2, 2026
6d487ae
chore: bump to 26.7.1
JeiKeiLim Jul 2, 2026
fa21292
docs(readme): restructure around model tiers, independent critics, do…
JeiKeiLim Jul 4, 2026
3ee9d18
Merge pull request #15 from JeiKeiLim/docs/readme-restructure
JeiKeiLim Jul 4, 2026
ab8ea9a
docs(readme): add Delivery Modes section for autonomous | agile
JeiKeiLim Jul 4, 2026
6b1d340
fix(eval): route journal entries through job ancestry when run_path i…
JeiKeiLim Jul 4, 2026
5343746
fix(eval): make start_eval output optional + drop fabricated critic stat
JeiKeiLim Jul 4, 2026
fa6761e
chore: bump to 26.7.2
JeiKeiLim Jul 4, 2026
f7b87d4
Add backlog.md
JeiKeiLim Jul 7, 2026
e22db9b
docs(skill): permit and recommend tracked sub-agent delegation in exe…
JeiKeiLim Jul 8, 2026
d0e43f6
Add backlogs
JeiKeiLim Jul 8, 2026
a7cb6ee
chore: bump to 26.7.3
JeiKeiLim Jul 8, 2026
b7c9ead
ci(publish): co-install sigstore for npx npm publish --provenance
JeiKeiLim Jul 8, 2026
5bec210
Update backlog
JeiKeiLim Jul 18, 2026
aefea6e
feat(db): add `tenet db cleanup` to reclaim SQLite bloat (#17)
JeiKeiLim Jul 19, 2026
b94b3d5
chore: bump to 26.7.4 (#18)
JeiKeiLim Jul 19, 2026
6fdfdc7
docs(agents): consolidate operational guide into AGENTS.md
JeiKeiLim Jul 20, 2026
26f7e4b
feat(critics): run-scoped critics + Critic Tailoring step (TASK-002)
JeiKeiLim Jul 20, 2026
589005f
docs(skill): split mode selection by timing, ask model_tier in all modes
JeiKeiLim Jul 21, 2026
1ad1502
Update backlog items
JeiKeiLim Jul 21, 2026
5115316
chore: bump to 26.7.5
JeiKeiLim Jul 21, 2026
50eec04
docs(skill): make sub-agent delegation the default for wait span
JeiKeiLim Jul 23, 2026
9706c88
fix: use cross-spawn for Windows .cmd shim resolution (#20)
JeiKeiLim Jul 28, 2026
14397a4
bump version 26.7.6
JeiKeiLim Jul 28, 2026
a3570e9
fix(eval): make opencode NDJSON output parseable + gate only latest e…
JeiKeiLim Aug 4, 2026
22c2b3b
fix(eval): address review nits — guard non-object NDJSON lines, >= ti…
JeiKeiLim Aug 4, 2026
278e2f0
fix(eval): export extractRubricJson for tests + brace-slice regressio…
JeiKeiLim Aug 4, 2026
aa627ff
feat(eval): round-id stamp so gate keys on complete rounds, not per-s…
JeiKeiLim Aug 4, 2026
18a9a3d
fix(eval): robust rubric extraction — rightmost object with a passed key
JeiKeiLim Aug 4, 2026
1c62cc9
fix(eval): only top-level passed objects count as rubrics
JeiKeiLim Aug 4, 2026
c60bc20
test(eval): C6 — sequential-mode round chaining (round-id stamped, pa…
JeiKeiLim Aug 4, 2026
cb0c1f8
fix(eval): harden rubric extraction + round-gate entry
JeiKeiLim Aug 5, 2026
b859487
fix(eval): track bracket depth in rubric scan + gate brace recovery o…
JeiKeiLim Aug 5, 2026
fb51c0e
fix(eval): rewrite brace recovery with matching-close + stage-prefere…
JeiKeiLim Aug 5, 2026
6b8824f
fix(eval): merge strict+recovery verdicts, fix recovery walk terminat…
JeiKeiLim Aug 6, 2026
b599ccd
fix(eval): recovery keeps rightmost verdict; tests for escaped-quote/…
JeiKeiLim Aug 6, 2026
e1f3d03
fix(eval): top-level guard in brace recovery; status surface uses the…
JeiKeiLim Aug 6, 2026
0ebdff5
fix(eval): isTopLevelish handles any stray-brace depth; unstamped rou…
JeiKeiLim Aug 6, 2026
eb2c5ab
fix(eval): stray-[ recovery, per-stage cohort guards; document staged…
JeiKeiLim Aug 6, 2026
d96ea3d
fix(eval): recovery runs for stage-less best; per-stage roster uses c…
JeiKeiLim Aug 6, 2026
b6e3a57
fix(eval): isTopLevelish string-state; drop 2-stage minimum; singleto…
JeiKeiLim Aug 6, 2026
dd43e4e
fix(eval): stamped-singleton consensus actually wired; merge order; u…
JeiKeiLim Aug 6, 2026
c42136b
fix(eval): truncated-enclosing guard; stamped singleton trusts own ro…
JeiKeiLim Aug 6, 2026
e194d06
fix(eval): truncated-array guard; document balanced-pair short-circui…
JeiKeiLim Aug 6, 2026
6321cc1
test(eval): pin opencode failure-path collapse; per-stage cancelled-c…
JeiKeiLim Aug 6, 2026
6235bc6
fix(eval): consensus requires majority; round gate keys on round start
JeiKeiLim Aug 6, 2026
8a7f10d
fix(eval): truncated-array direct-object guard; escaped-quote key reg…
JeiKeiLim Aug 6, 2026
d176826
test(eval): production-data golden test for the rubric parser
JeiKeiLim Aug 7, 2026
1ac1546
refactor(eval): simplify rubric parser to a single rightmost-walk
JeiKeiLim Aug 7, 2026
914ca61
test(eval): sanitize golden fixture — remove production-identifying c…
JeiKeiLim Aug 7, 2026
6948399
Merge origin/main into fix/opencode-output-and-blocking-finding-resume
JeiKeiLim Aug 7, 2026
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
255 changes: 255 additions & 0 deletions src/adapters/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import { vi } from 'vitest';
import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js';
import { AdapterRegistry, parseAdapterExtraArgs } from './index.js';
Expand Down Expand Up @@ -207,3 +209,256 @@ describe('adapter extraArgs passthrough', () => {
expect(argv[1]).toBe('--dangerously-bypass-approvals-and-sandbox');
});
});

describe('OpenCodeAdapter NDJSON output collapse', () => {
beforeEach(() => {
spawnMock.mockReset();
});

const fixturePath = path.resolve(
__dirname,
'..',
'..',
'tests',
'fixtures',
'fake-agents',
'opencode-ndjson-text-parts.json',
);

it('collapses the event stream into text-part contents (prose + trailing verdict)', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
spawnMock.mockImplementation(() => makeFakeChild(0, ndjson));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

expect(response.success).toBe(true);
expect(response.output).toContain('All 12 tests pass.');
expect(response.output).toContain('"passed": true');
// Raw NDJSON event framing must not leak through.
expect(response.output).not.toContain('"type":"step_start"');
expect(response.output).not.toContain('"type":"tool_use"');
});

it('joins multiple text events in stream order (real streams emit many)', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
spawnMock.mockImplementation(() => makeFakeChild(0, ndjson));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

const zeroFindings = response.output.indexOf('zero-findings recheck');
const verdict = response.output.indexOf('All 12 tests pass');
expect(zeroFindings).toBeGreaterThanOrEqual(0);
expect(verdict).toBeGreaterThan(zeroFindings);
});

it('end-to-end: collapsed output yields a passed rubric through extractRubricJson', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
spawnMock.mockImplementation(() => makeFakeChild(0, ndjson));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

// The real production parser — not a mirror. Mirrors drift.
const { extractRubricJson } = await import('../core/rubric.js');
const parsed = extractRubricJson(response.output);
expect(parsed).not.toBeNull();
expect(parsed?.passed).toBe(true);
expect(parsed?.stage).toBe('code_critic');
});

it('preserves the verdict even when the final line is truncated (context-limit tail)', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
// Cut mid-event: a context-limit kill can truncate the stream before the
// final step_finish. The text part (with the rubric) must still survive.
const truncated = ndjson.split('\n').slice(0, -1).join('\n') + '\n{"type":"step_finish","timestamp":1785417871';
spawnMock.mockImplementation(() => makeFakeChild(0, truncated));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

expect(response.success).toBe(true);
expect(response.output).toContain('"passed": true');
});

it('falls back to raw stdout when no text part parses', async () => {
const garbage = 'opencode: something went wrong\n{"type":"error","message":"boom"';
spawnMock.mockImplementation(() => makeFakeChild(0, garbage));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'hi' });

expect(response.output).toBe(garbage);
});

it('skips valid-JSON non-event lines (null, numbers) without crashing', async () => {
const mixed = 'null\n42\n{"type":"step_start"}\n{"type":"text","part":{"text":"verdict here"}}';
spawnMock.mockImplementation(() => makeFakeChild(0, mixed));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'hi' });

expect(response.success).toBe(true);
expect(response.output).toBe('verdict here');
});

it('skips text events whose part.text is not a string (shape-change guard)', async () => {
// The typeof guard is the load-bearing line against a future opencode
// shape change (e.g. part.text becoming an object). A truthy check would
// push a non-string into parts and emit "[object Object]" into the output
// that feeds rubric extraction — this test pins the guard.
const mixed = [
'{"type":"text","part":{"text":{"nested":true}}}',
'{"type":"text","part":{"text":42}}',
'{"type":"text","part":{"text":"real verdict here"}}',
].join('\n');
spawnMock.mockImplementation(() => makeFakeChild(0, mixed));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'hi' });

expect(response.success).toBe(true);
expect(response.output).toBe('real verdict here');
expect(response.output).not.toContain('[object Object]');
});

it('verdict survives a later text event containing real braces (extractRubricJson scan)', async () => {
const stream = [
'{"type":"step_start","part":{"id":"a","type":"step-start"}}',
'{"type":"text","part":{"text":"{\\"passed\\": true, \\"stage\\": \\"code_critic\\", \\"findings\\": []}"}}',
'{"type":"text","part":{"text":"Note: the fix touched { src/foo.ts } and { src/bar.ts } (see commit 4b8b12f)."}}',
'{"type":"step_finish","part":{"id":"b","reason":"stop","type":"step-finish"}}',
].join('\n');
spawnMock.mockImplementation(() => makeFakeChild(0, stream));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

const { extractRubricJson } = await import('../core/rubric.js');
const parsed = extractRubricJson(response.output);
expect(parsed).not.toBeNull();
expect(parsed?.passed).toBe(true);
});

it('extractRubricJson ignores a trailing object without a passed key', async () => {
const { extractRubricJson } = await import('../core/rubric.js');
const output = [
'I reviewed the diff. Verdict:',
'{"passed": true, "stage": "code_critic", "findings": []}',
'{"note": "this is a trailing note, not a verdict"}',
].join('\n');
const parsed = extractRubricJson(output);
expect(parsed).not.toBeNull();
expect(parsed?.passed).toBe(true);
expect(parsed?.stage).toBe('code_critic');
});

it('extractRubricJson returns null when no object has a passed key', async () => {
const { extractRubricJson } = await import('../core/rubric.js');
const output = 'I began my review but ran out of context before finishing.';
expect(extractRubricJson(output)).toBeNull();
});

it('extractRubricJson never picks a nested passed object over the top-level verdict', async () => {
const { extractRubricJson } = await import('../core/rubric.js');

// t1: failing verdict + trailing tool result with nested passed:true —
// must return the FAILING verdict, not false-green the gate.
const t1 = [
'V: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x"}]}',
'Tool output: {"results": [{"name": "syntax", "passed": true}]}',
].join('\n');
const r1 = extractRubricJson(t1);
expect(r1).not.toBeNull();
expect(r1?.passed).toBe(false);
expect(r1?.stage).toBe('code_critic');

// t2: passing verdict + trailing object with nested passed:false —
// must return the PASSING verdict, not false-strand the parent.
const t2 = [
'V: {"passed": true, "stage": "code_critic", "findings": []}',
'{"checks": {"lint": {"passed": false}}}',
].join('\n');
const r2 = extractRubricJson(t2);
expect(r2).not.toBeNull();
expect(r2?.passed).toBe(true);
expect(r2?.stage).toBe('code_critic');
});

it('extractRubricJson handles the custom-critic shape (nested assertions in the top-level verdict)', async () => {
const { extractRubricJson } = await import('../core/rubric.js');
const output = JSON.stringify({
passed: true,
stage: 'credit_ledger_integrity',
assertions: [
{ name: 'append_only', passed: true, evidence: 'ledger is append-only' },
{ name: 'audit_fields', passed: true, evidence: 'created_at set' },
],
findings: [],
});
const parsed = extractRubricJson(output);
expect(parsed).not.toBeNull();
expect(parsed?.passed).toBe(true);
expect(parsed?.stage).toBe('credit_ledger_integrity');
});

it('collapses NDJSON on a non-zero exit (failure path)', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
spawnMock.mockImplementation(() => makeFakeChild(1, ndjson));

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

expect(response.success).toBe(false);
// The collapse applies on failure paths too (documented divergence from
// claude/codex, which return raw stdout).
expect(response.output).toContain('"passed": true');
expect(response.output).not.toContain('"type":"step_start"');
});

it('collapses NDJSON on a spawn error (failure path)', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
const child = new EventEmitter() as FakeChild;
child.stdin = { write: () => undefined, end: () => undefined };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = () => undefined;
setImmediate(() => {
child.stdout.emit('data', Buffer.from(ndjson));
child.emit('error', new Error('spawn ENOENT'));
});
spawnMock.mockImplementation(() => child);

const adapter = new OpenCodeAdapter();
const response = await adapter.invoke({ prompt: 'review this' });

expect(response.success).toBe(false);
expect(response.output).toContain('"passed": true');
});

it('collapses NDJSON on a timeout (failure path)', async () => {
const ndjson = fs.readFileSync(fixturePath, 'utf8');
const child = new EventEmitter() as FakeChild;
child.stdin = { write: () => undefined, end: () => undefined };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
// The adapter's timeout handler kills the child; a real child then emits
// 'close'. Emit it so the promise resolves through the timedOut branch.
child.kill = () => {
setImmediate(() => child.emit('close', 1));
};
setImmediate(() => {
child.stdout.emit('data', Buffer.from(ndjson));
// Never emit 'close' on our own — the adapter's timeout must fire.
});
spawnMock.mockImplementation(() => child);

const adapter = new OpenCodeAdapter(50);
const response = await adapter.invoke({ prompt: 'review this' });

expect(response.success).toBe(false);
expect(response.error).toContain('timed out');
expect(response.output).toContain('"passed": true');
});
});
55 changes: 52 additions & 3 deletions src/adapters/opencode-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,55 @@ import spawn from 'cross-spawn';
import { DEFAULT_JOB_TIMEOUT_MS } from '../core/runtime-config.js';
import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js';

/**
* opencode --format json emits a stream of NDJSON events (step_start, tool_use,
* text, step_finish...). The assistant's actual message content lives in the
* `text` events' `part.text`. Collapse the stream into those text parts joined
* by newlines so downstream consumers (rubric extraction, job_result, worker
* output) see plain text like the claude adapter produces — not raw event
* bytes. Returns null when no text part parsed (e.g. an error banner or empty
* stdout) so callers can fall back to the raw stream.
*
* NOTE: the collapse is applied on ALL resolve paths (success, timeout,
* non-zero exit, spawn error), unlike the claude/codex adapters which return
* raw stdout on failure. This is deliberate (a partial NDJSON stream is more
* useful collapsed), but it means a failed opencode job's stored output drops
* the raw event stream (including any error-type event) — observability only,
* since rubric extraction runs only on success.
*
* Schema pin: this matches the event shape opencode emits today (each line is
* `{"type":"text", ..., "part":{"type":"text","text":"..."}}`). If a future
* opencode bump changes the part shape (e.g. a `parts[]` array), this collapse
* silently degrades to the raw-stream fallback — the fixture
* `tests/fixtures/fake-agents/opencode-ndjson-text-parts.json` and the
* end-to-end test in `src/adapters/adapter.test.ts` will catch it. Re-verify
* against live output when bumping opencode.
*/
export const extractTextPartsFromNdjson = (stdout: string): string | null => {
const parts: string[] = [];
for (const line of stdout.split('\n')) {
if (!line.trim()) {
continue;
}
let event: unknown;
try {
event = JSON.parse(line);
} catch {
// Skip malformed lines — a context-limit kill can truncate the tail mid-event.
continue;
}
if (!event || typeof event !== 'object') {
// Valid JSON that isn't an event object (e.g. `null`, numbers) — skip.
continue;
}
const record = event as { type?: unknown; part?: { text?: unknown } };
if (record.type === 'text' && typeof record.part?.text === 'string') {
parts.push(record.part.text);
}
}
return parts.length > 0 ? parts.join('\n') : null;
};

export class OpenCodeAdapter implements AgentAdapter {
public readonly name = 'opencode';
private readonly timeoutMs: number;
Expand Down Expand Up @@ -54,7 +103,7 @@ export class OpenCodeAdapter implements AgentAdapter {
if (timedOut) {
resolve({
success: false,
output: stdout,
output: extractTextPartsFromNdjson(stdout) ?? stdout,
error: `opencode invocation timed out after ${effectiveTimeout}ms`,
durationMs,
});
Expand All @@ -63,7 +112,7 @@ export class OpenCodeAdapter implements AgentAdapter {

resolve({
success: code === 0,
output: stdout,
output: extractTextPartsFromNdjson(stdout) ?? stdout,
error: code === 0 ? undefined : stderr || `opencode exited with code ${code ?? 'unknown'}`,
durationMs,
});
Expand All @@ -73,7 +122,7 @@ export class OpenCodeAdapter implements AgentAdapter {
clearTimeout(timeout);
resolve({
success: false,
output: stdout,
output: extractTextPartsFromNdjson(stdout) ?? stdout,
error: error.message,
durationMs: Date.now() - startedAt,
});
Expand Down
Loading
Loading