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
5 changes: 5 additions & 0 deletions .changeset/calm-dolphins-buffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-opencode': patch
---

fix(ai-opencode): bound memory while buffering parts before their message role is known
8 changes: 7 additions & 1 deletion packages/ai-opencode/src/stream/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions packages/ai-opencode/tests/translate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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,
Expand Down
Loading