Skip to content

Commit dc36fb8

Browse files
alari76claude
andauthored
fix: use correct REST endpoint to classify OpenCode reasoning parts (#515)
The previous fix fetched GET /session/{sid}/message/{mid}/part/{pid} to determine a part's kind, but that single-part route is not served — it returns the OpenCode SPA HTML with HTTP 200, so res.json() threw and classifyPart fell back to its 'text' default, leaving reasoning leaking into the transcript exactly as before. Fetch the message instead (GET /session/{sid}/message/{mid}), which returns { info, parts: [{ id, type }] }, and record the kind for every part in one round-trip. Verified live that this returns the reasoning part with type=reasoning mid-stream, when classification actually runs. Also add a turn-end safety net (flushPendingParts) so any part still awaiting classification is resolved and flushed before the result is emitted — text is never dropped, reasoning stays hidden. Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
1 parent 4486d73 commit dc36fb8

2 files changed

Lines changed: 70 additions & 14 deletions

File tree

server/opencode-process.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,21 @@ describe('OpenCodeProcess', () => {
315315
ocp.on('thinking', thinkingHandler)
316316
setSessionId(ocp, 'oc-session-1')
317317

318-
// Classify 'prt_reason' as reasoning, everything else as text.
318+
// classifyPart fetches GET /session/{sid}/message/{mid}, which returns the
319+
// message with all its parts and their types.
319320
mockFetch.mockImplementation((url: string) => {
320-
const type = url.includes('prt_reason') ? 'reasoning' : 'text'
321-
return Promise.resolve({ ok: true, json: () => Promise.resolve({ type }) })
321+
if (url.includes('/message/msg_1')) {
322+
return Promise.resolve({
323+
ok: true,
324+
json: () => Promise.resolve({
325+
parts: [
326+
{ id: 'prt_reason', type: 'reasoning' },
327+
{ id: 'prt_answer', type: 'text' },
328+
],
329+
}),
330+
})
331+
}
332+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
322333
})
323334

324335
for (const d of ['The user ', 'is greeting me. ', 'I should respond.']) {
@@ -335,6 +346,7 @@ describe('OpenCodeProcess', () => {
335346
await vi.waitFor(() => expect(thinkingHandler).toHaveBeenCalledTimes(1))
336347
expect(textHandler).not.toHaveBeenCalled()
337348

349+
// The answer part was classified by the same fetch, so its deltas show.
338350
for (const d of ['Hello', '! How can I help?']) {
339351
callHandleSSE(ocp, {
340352
type: 'message.part.delta',

server/opencode-process.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
458458
private partDeltaBuffers = new Map<string, string[]>()
459459
/** partIDs with an in-flight classifyPart REST lookup (dedup guard). */
460460
private partClassifyInflight = new Set<string>()
461+
/** Most recent assistant messageID seen on a delta — used to classify stragglers at turn end. */
462+
private lastDeltaMessageId = ''
461463

462464
constructor(workingDir: string, opts?: Partial<OpenCodeProcessOptions>) {
463465
super()
@@ -786,35 +788,62 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
786788
}
787789

788790
/**
789-
* Resolve a part's kind via REST when the SSE stream gives no type signal.
791+
* Resolve part kinds via REST when the SSE stream gives no type signal.
790792
* OpenCode's delta stream uses `field=text` for both reasoning and text
791793
* parts, and some providers (e.g. Kimi/ollama-cloud) never emit
792-
* message.part.updated, so the part must be fetched to know whether its
793-
* deltas are visible response text or hidden reasoning. Defaults to showing
794-
* the content on any failure — better to display than to silently hide.
794+
* message.part.updated, so the message must be fetched to learn whether a
795+
* part's deltas are visible response text or hidden reasoning.
796+
*
797+
* Fetches the whole message (`GET /session/{sid}/message/{mid}`) and records
798+
* the kind for EVERY part it contains, so a single round-trip classifies all
799+
* of a message's parts. If the target part isn't listed yet (a delta can
800+
* briefly outrace the parts snapshot), the in-flight guard is released so the
801+
* next delta retries — its deltas stay buffered (hidden) until resolved.
795802
*/
796803
private async classifyPart(messageID: string, partID: string): Promise<void> {
797804
if (this.partKinds.has(partID) || this.partClassifyInflight.has(partID)) return
798805
this.partClassifyInflight.add(partID)
799-
let kind: 'reasoning' | 'text' | 'other' = 'text'
800806
try {
801807
const baseUrl = `http://localhost:${serverState.port}`
802808
const res = await fetch(
803-
`${baseUrl}/session/${this.opencodeSessionId}/message/${messageID}/part/${partID}`,
809+
`${baseUrl}/session/${this.opencodeSessionId}/message/${messageID}`,
804810
{
805811
headers: { ...authHeaders(), 'x-opencode-directory': this.workingDir },
806812
signal: AbortSignal.timeout(10_000),
807813
},
808814
)
809815
if (res.ok) {
810-
const part = await res.json() as { type?: string }
811-
kind = part.type === 'reasoning' ? 'reasoning' : part.type === 'text' ? 'text' : 'other'
816+
const msg = await res.json() as { parts?: Array<{ id?: string; type?: string }> }
817+
for (const part of msg.parts ?? []) {
818+
if (!part.id || this.partKinds.has(part.id)) continue
819+
const kind = part.type === 'reasoning' ? 'reasoning' : part.type === 'text' ? 'text' : 'other'
820+
this.recordPartKind(part.id, kind)
821+
}
812822
}
813823
} catch {
814-
// Network/timeout — fall back to showing the content as text.
824+
// Network/timeout — leave unresolved; a later delta retries the lookup.
815825
}
816826
this.partClassifyInflight.delete(partID)
817-
this.recordPartKind(partID, kind)
827+
}
828+
829+
/**
830+
* At turn end, resolve any parts whose kind never came back during streaming,
831+
* then flush whatever is still buffered. A final classification keeps reasoning
832+
* hidden; anything that still can't be resolved (total REST failure) is shown
833+
* as text rather than silently dropping the answer.
834+
*/
835+
private async flushPendingParts(): Promise<void> {
836+
if (this.partDeltaBuffers.size === 0) return
837+
if (this.lastDeltaMessageId) {
838+
for (const pid of [...this.partDeltaBuffers.keys()]) {
839+
this.partClassifyInflight.delete(pid)
840+
await this.classifyPart(this.lastDeltaMessageId, pid)
841+
}
842+
}
843+
for (const [pid, buf] of [...this.partDeltaBuffers.entries()]) {
844+
this.partDeltaBuffers.delete(pid)
845+
for (const d of buf) this.emitTextDelta(d)
846+
}
818847
}
819848

820849
/** Flush any buffered text deltas that haven't been emitted yet (e.g. turn ended before buffer threshold). */
@@ -840,6 +869,17 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
840869
this.turnComplete = true
841870
this.turnInFlight = false
842871
this.clearTurnWatchdog()
872+
// If any part deltas are still buffered awaiting classification, resolve and
873+
// flush them before finalizing so their text isn't lost or misordered.
874+
if (this.partDeltaBuffers.size > 0) {
875+
void this.flushPendingParts().finally(() => { this.finalizeTurn() })
876+
} else {
877+
this.finalizeTurn()
878+
}
879+
}
880+
881+
/** Emit the turn result and dispatch any message queued mid-turn. */
882+
private finalizeTurn(): void {
843883
this.flushDeltaBuffer()
844884
this.emit('result', '', false)
845885
// Send the next queued message (received mid-turn) after result handlers run.
@@ -905,7 +945,10 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
905945
if (buf) buf.push(delta)
906946
else this.partDeltaBuffers.set(partID, [delta])
907947
const messageID = properties.messageID as string | undefined
908-
if (messageID) void this.classifyPart(messageID, partID)
948+
if (messageID) {
949+
this.lastDeltaMessageId = messageID
950+
void this.classifyPart(messageID, partID)
951+
}
909952
} else if (field === 'reasoning' && delta) {
910953
// Some providers send reasoning on a dedicated `field=reasoning`
911954
// stream — always hidden from the transcript.
@@ -1472,6 +1515,7 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
14721515
this.partKinds.clear()
14731516
this.partDeltaBuffers.clear()
14741517
this.partClassifyInflight.clear()
1518+
this.lastDeltaMessageId = ''
14751519
this.startTurnWatchdog()
14761520

14771521
const baseUrl = `http://localhost:${serverState.port}`

0 commit comments

Comments
 (0)