diff --git a/src/profiles/researcher.ts b/src/profiles/researcher.ts index ba866bf..a66468d 100644 --- a/src/profiles/researcher.ts +++ b/src/profiles/researcher.ts @@ -410,6 +410,15 @@ 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) + // 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) + 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 } } @@ -417,7 +426,7 @@ function parseResearcherEvents(events: SandboxEvent[]): ResearchOutput { 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 @@ -431,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 e49076d..03312f2 100644 --- a/tests/profiles/researcher.test.ts +++ b/tests/profiles/researcher.test.ts @@ -399,6 +399,65 @@ 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: [{ 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` }, + }, + ] + 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', () => { + 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) + 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) + }) }) describe('multiHarnessResearcherFanout', () => {