diff --git a/.changeset/calm-dolphins-buffer.md b/.changeset/calm-dolphins-buffer.md new file mode 100644 index 0000000000..dfbf559bc3 --- /dev/null +++ b/.changeset/calm-dolphins-buffer.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-opencode': patch +--- + +fix(ai-opencode): bound memory while buffering parts before their message role is known diff --git a/packages/ai-opencode/src/stream/translate.ts b/packages/ai-opencode/src/stream/translate.ts index 8889bd0240..1d3094e9c9 100644 --- a/packages/ai-opencode/src/stream/translate.ts +++ b/packages/ai-opencode/src/stream/translate.ts @@ -375,7 +375,13 @@ export async function* translateOpencodeStream( const role = messageRoles.get(part.messageID) if (role === undefined) { const parts = pendingParts.get(part.messageID) ?? [] - parts.push({ part, delta }) + // A text or reasoning part carries its full text, so keep only its + // latest update. Keeping each update holds every snapshot in memory. + const index = isToolPart(part) + ? -1 + : parts.findIndex((pending) => pending.part.id === part.id) + if (index === -1) parts.push({ part, delta }) + else parts[index] = { part } pendingParts.set(part.messageID, parts) } else if (role === 'assistant') { yield* handlePart(part, delta) diff --git a/packages/ai-opencode/tests/translate.test.ts b/packages/ai-opencode/tests/translate.test.ts index 5ec94c31f6..a62b71ce68 100644 --- a/packages/ai-opencode/tests/translate.test.ts +++ b/packages/ai-opencode/tests/translate.test.ts @@ -146,6 +146,7 @@ describe('translateOpencodeStream', () => { it('accumulates incremental text deltas', async () => { const chunks = await collect([ session, + messageUpdated('msg-1', 'assistant'), textPart('part-1', 'Hel', 'Hel'), textPart('part-1', 'Hello', 'lo'), done(), @@ -164,6 +165,7 @@ describe('translateOpencodeStream', () => { it('derives the delta from full-text snapshots when no delta is given', async () => { const chunks = await collect([ session, + messageUpdated('msg-1', 'assistant'), textPart('part-1', 'Hel'), textPart('part-1', 'Hello'), done(), @@ -173,6 +175,23 @@ describe('translateOpencodeStream', () => { expect(contents[1]).toMatchObject({ delta: 'lo', content: 'Hello' }) }) + it('keeps only the latest update of a part while its role is unknown', async () => { + const chunks = await collect([ + session, + textPart('part-1', 'The', 'The'), + textPart('part-1', 'The answer', ' answer'), + textPart('part-1', 'The answer is 42', ' is 42'), + done(), + ]) + const contents = chunks.filter((c) => c.type === 'TEXT_MESSAGE_CONTENT') + + expect(contents).toHaveLength(1) + expect(contents[0]).toMatchObject({ + delta: 'The answer is 42', + content: 'The answer is 42', + }) + }) + it('reports usage with cache and reasoning details', async () => { const chunks = await collect([ session,