From eb83ba909d82cdba0da0e7c8be5428d0c4d286c3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 14 Jun 2026 16:19:56 -0600 Subject: [PATCH 1/3] fix(profiles): researcher reads opencode finalText so successful runs aren't parsed as empty --- src/profiles/researcher.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/profiles/researcher.ts b/src/profiles/researcher.ts index ba866bf..77961b7 100644 --- a/src/profiles/researcher.ts +++ b/src/profiles/researcher.ts @@ -411,13 +411,21 @@ function parseResearcherEvents(events: SandboxEvent[]): ResearchOutput { if (type === 'result' || type === 'final' || type === 'research.result') { const direct = coerceResearchOutput(data.result ?? data.output ?? data) if (direct) return direct + // opencode reports the agent's terminal answer in `finalText` (not result/output); + // without this, a SUCCESSFUL opencode research run parses to an empty payload. + const finalText = pickString(data.finalText) + if (finalText) { + const fenced = extractFencedJson(finalText) + const coerced = fenced ? coerceResearchOutput(fenced) : undefined + if (coerced) return coerced + } } } for (let i = events.length - 1; i >= 0; i -= 1) { const event = events[i] if (!event) continue const data = isRecord(event.data) ? event.data : {} - const text = pickString(data.text) ?? pickString(data.delta) + const text = pickString(data.text) ?? pickString(data.delta) ?? pickString(data.finalText) if (!text) continue const fenced = extractFencedJson(text) if (!fenced) continue From 9a62b83d29932cf102021ff5fa6960464802a18e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 14 Jun 2026 16:40:46 -0600 Subject: [PATCH 2/3] test(profiles): cover finalText parse paths; drop historical-narrative comment Addresses PR #26 audit: MEDIUM (no test coverage for finalText paths) + LOW (historical narrative in comment, forbidden by AGENTS.md). --- src/profiles/researcher.ts | 3 +-- tests/profiles/researcher.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/profiles/researcher.ts b/src/profiles/researcher.ts index 77961b7..8f30a2f 100644 --- a/src/profiles/researcher.ts +++ b/src/profiles/researcher.ts @@ -411,8 +411,7 @@ function parseResearcherEvents(events: SandboxEvent[]): ResearchOutput { if (type === 'result' || type === 'final' || type === 'research.result') { const direct = coerceResearchOutput(data.result ?? data.output ?? data) if (direct) return direct - // opencode reports the agent's terminal answer in `finalText` (not result/output); - // without this, a SUCCESSFUL opencode research run parses to an empty payload. + // opencode reports the agent's terminal answer in `finalText` (not result/output). const finalText = pickString(data.finalText) if (finalText) { const fenced = extractFencedJson(finalText) diff --git a/tests/profiles/researcher.test.ts b/tests/profiles/researcher.test.ts index e49076d..c43496b 100644 --- a/tests/profiles/researcher.test.ts +++ b/tests/profiles/researcher.test.ts @@ -399,6 +399,32 @@ describe('loose-output passthrough', () => { const parsed = adapter.parse([{ type: 'noise', data: { random: 1 } }]) expect(parsed).toEqual({ items: [], citations: [], proposedWrites: [] }) }) + + it('parses a result event whose answer is only in finalText (opencode)', () => { + const { output: adapter } = researcherProfile() + const payload = { + items: [item()], + citations: [{ url: 'https://x.com/1', quote: 'q', confidence: 0.8 }], + proposedWrites: [], + } + // opencode puts the terminal answer in `finalText`, not `result`/`output`. + const events: SandboxEvent[] = [ + { type: 'result', data: { finalText: `done:\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\`\n` } }, + ] + const parsed = adapter.parse(events) + expect(parsed.items).toHaveLength(1) + expect(parsed.citations).toHaveLength(1) + }) + + it('mines fenced JSON from finalText in the text-scan fallback', () => { + const { output: adapter } = researcherProfile() + const payload = { items: [item()], citations: [], proposedWrites: [] } + const events: SandboxEvent[] = [ + { type: 'message', data: { finalText: `\`\`\`json\n${JSON.stringify(payload)}\n\`\`\`` } }, + ] + const parsed = adapter.parse(events) + expect(parsed.items).toHaveLength(1) + }) }) describe('multiHarnessResearcherFanout', () => { From 2fefefd31091c4f3fd96e2d41f7799e819b9e0d9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 14 Jun 2026 17:09:23 -0600 Subject: [PATCH 3/3] fix(profiles): prefer rich finalText over empty result shape; strengthen tests Round-2 PR #26 audit (all LOW): - Empty-array shadow: an event carrying both an all-empty typed result and a rich finalText no longer drops the real answer (isEmptyOutput guard). - Tests: assert citations/proposedWrites on the finalText paths; negative test for non-JSON finalText; regression test for the shadow case. --- src/profiles/researcher.ts | 19 +++++++++++----- tests/profiles/researcher.test.ts | 37 +++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/profiles/researcher.ts b/src/profiles/researcher.ts index 8f30a2f..a66468d 100644 --- a/src/profiles/researcher.ts +++ b/src/profiles/researcher.ts @@ -410,14 +410,16 @@ function parseResearcherEvents(events: SandboxEvent[]): ResearchOutput { const data = isRecord(event.data) ? event.data : {} if (type === 'result' || type === 'final' || type === 'research.result') { const direct = coerceResearchOutput(data.result ?? data.output ?? data) - if (direct) return direct // opencode reports the agent's terminal answer in `finalText` (not result/output). + // Parse it too, and prefer it when `direct` is an all-empty shape — otherwise an + // event carrying both `{items:[],citations:[],proposedWrites:[]}` and a rich + // finalText would silently drop the real answer. const finalText = pickString(data.finalText) - if (finalText) { - const fenced = extractFencedJson(finalText) - const coerced = fenced ? coerceResearchOutput(fenced) : undefined - if (coerced) return coerced - } + const fenced = finalText ? extractFencedJson(finalText) : undefined + const fromFinalText = fenced ? coerceResearchOutput(fenced) : undefined + if (direct && !isEmptyOutput(direct)) return direct + if (fromFinalText) return fromFinalText + if (direct) return direct } } for (let i = events.length - 1; i >= 0; i -= 1) { @@ -438,6 +440,11 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } +/** A coerced output that carries no items, citations, or proposed writes. */ +function isEmptyOutput(o: ResearchOutput): boolean { + return o.items.length === 0 && o.citations.length === 0 && o.proposedWrites.length === 0 +} + function pickString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } diff --git a/tests/profiles/researcher.test.ts b/tests/profiles/researcher.test.ts index c43496b..03312f2 100644 --- a/tests/profiles/researcher.test.ts +++ b/tests/profiles/researcher.test.ts @@ -405,15 +405,19 @@ describe('loose-output passthrough', () => { const payload = { items: [item()], citations: [{ url: 'https://x.com/1', quote: 'q', confidence: 0.8 }], - proposedWrites: [], + proposedWrites: [{ kind: 'insert', namespace: 'cust_42', item: item() }], } // opencode puts the terminal answer in `finalText`, not `result`/`output`. const events: SandboxEvent[] = [ - { type: 'result', data: { finalText: `done:\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\`\n` } }, + { + type: 'result', + data: { finalText: `done:\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\`\n` }, + }, ] const parsed = adapter.parse(events) expect(parsed.items).toHaveLength(1) expect(parsed.citations).toHaveLength(1) + expect(parsed.proposedWrites).toHaveLength(1) }) it('mines fenced JSON from finalText in the text-scan fallback', () => { @@ -424,6 +428,35 @@ describe('loose-output passthrough', () => { ] const parsed = adapter.parse(events) expect(parsed.items).toHaveLength(1) + expect(parsed.citations).toHaveLength(0) + expect(parsed.proposedWrites).toHaveLength(0) + }) + + it('falls through to empty when finalText carries plain prose (no fenced JSON)', () => { + const { output: adapter } = researcherProfile() + const events: SandboxEvent[] = [ + { type: 'result', data: { finalText: 'I could not find anything relevant.' } }, + ] + expect(adapter.parse(events)).toEqual({ items: [], citations: [], proposedWrites: [] }) + }) + + it('prefers a rich finalText over an all-empty direct result shape on the same event', () => { + const { output: adapter } = researcherProfile() + const payload = { items: [item()], citations: [], proposedWrites: [] } + // Same event carries BOTH an empty typed shape and a rich finalText — the empty + // shape must not shadow the real answer. + const events: SandboxEvent[] = [ + { + type: 'result', + data: { + items: [], + citations: [], + proposedWrites: [], + finalText: `\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``, + }, + }, + ] + expect(adapter.parse(events).items).toHaveLength(1) }) })