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
16 changes: 15 additions & 1 deletion src/profiles/researcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,14 +410,23 @@ 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
}
}
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
Expand All @@ -431,6 +440,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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
}
Expand Down
59 changes: 59 additions & 0 deletions tests/profiles/researcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading