From dcbea9008d0eb1105812fff2d1a3611187abaeae Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:54:46 +0000 Subject: [PATCH 1/2] fix(opencode): guard unwrapResponse array iteration in background manager When the API returns {} instead of [] for empty session/children lists, unwrapResponse returns a truthy non-iterable object. The ?? [] fallback does not trigger because {} is truthy. This causes a TypeError: {} is not iterable when iterating with for...of. Replace ?? [] with explicit Array.isArray() guards at the two affected call sites in recoverTasks() and refreshStatuses(), matching the existing pattern already used in inspectTask() and fetchLatestResult(). Fixes #966 Co-Authored-By: Rick Blalock --- packages/opencode/src/background/manager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/background/manager.ts b/packages/opencode/src/background/manager.ts index 3f7902e9..fc638b38 100644 --- a/packages/opencode/src/background/manager.ts +++ b/packages/opencode/src/background/manager.ts @@ -183,7 +183,8 @@ export class BackgroundManager { throwOnError: false, }); - const children = unwrapResponse>(childrenResponse) ?? []; + const rawChildren = unwrapResponse>(childrenResponse); + const children = Array.isArray(rawChildren) ? rawChildren : []; for (const child of children) { const childSession = child as { id?: string; status?: { type?: string } }; if (!childSession.id) continue; @@ -240,7 +241,8 @@ export class BackgroundManager { throwOnError: false, }); - const sessions = unwrapResponse>(sessionsResponse) ?? []; + const rawSessions = unwrapResponse>(sessionsResponse); + const sessions = Array.isArray(rawSessions) ? rawSessions : []; for (const session of sessions) { const sess = session as { From ec8a9d6e2c09832ab203b11e082f86dbe40d28be Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:20:46 +0000 Subject: [PATCH 2/2] test(opencode): add BackgroundManager tests for non-array API responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests recoverTasks and refreshStatuses handle cases where the API returns {} instead of [] — the exact scenario that caused the TypeError in #966. Also tests undefined, null, valid array, valid session recovery, and non-bg_ task filtering. Co-Authored-By: Rick Blalock --- packages/opencode/test/background.test.ts | 140 ++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/packages/opencode/test/background.test.ts b/packages/opencode/test/background.test.ts index f87e8d45..96c50f6d 100644 --- a/packages/opencode/test/background.test.ts +++ b/packages/opencode/test/background.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, beforeEach } from 'bun:test'; import { ConcurrencyManager } from '../src/background/concurrency'; +import { BackgroundManager } from '../src/background/manager'; import type { BackgroundTask, BackgroundTaskStatus, TaskProgress } from '../src/background/types'; +import type { PluginInput } from '@opencode-ai/plugin'; describe('Background', () => { describe('ConcurrencyManager', () => { @@ -266,4 +268,142 @@ describe('Background', () => { expect(progress.lastTool).toBe('bash'); }); }); + + describe('BackgroundManager', () => { + function createMockCtx(overrides?: { + sessionList?: () => Promise; + sessionChildren?: () => Promise; + }): PluginInput { + return { + client: { + session: { + list: overrides?.sessionList ?? (async () => ({ data: [] })), + children: overrides?.sessionChildren ?? (async () => ({ data: [] })), + get: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + create: async () => ({ data: { id: 'sess_1' } }), + prompt: async () => ({}), + abort: async () => ({}), + status: async () => ({ data: {} }), + }, + }, + } as unknown as PluginInput; + } + + describe('recoverTasks', () => { + it('does not throw when session.list returns an empty object instead of an array', async () => { + const ctx = createMockCtx({ + sessionList: async () => ({ data: {} }), + }); + const mgr = new BackgroundManager(ctx); + const recovered = await mgr.recoverTasks(); + expect(recovered).toBe(0); + }); + + it('does not throw when session.list returns undefined', async () => { + const ctx = createMockCtx({ + sessionList: async () => ({ data: undefined }), + }); + const mgr = new BackgroundManager(ctx); + const recovered = await mgr.recoverTasks(); + expect(recovered).toBe(0); + }); + + it('does not throw when session.list returns null', async () => { + const ctx = createMockCtx({ + sessionList: async () => ({ data: null }), + }); + const mgr = new BackgroundManager(ctx); + const recovered = await mgr.recoverTasks(); + expect(recovered).toBe(0); + }); + + it('returns 0 when session.list returns an empty array', async () => { + const ctx = createMockCtx({ + sessionList: async () => ({ data: [] }), + }); + const mgr = new BackgroundManager(ctx); + const recovered = await mgr.recoverTasks(); + expect(recovered).toBe(0); + }); + + it('recovers tasks from valid session data', async () => { + const taskMeta = JSON.stringify({ + taskId: 'bg_abc123', + agent: 'scout', + description: 'Test task', + createdAt: new Date().toISOString(), + }); + const ctx = createMockCtx({ + sessionList: async () => ({ + data: [ + { + id: 'sess_1', + title: taskMeta, + parentID: 'parent_1', + status: { type: 'running' }, + }, + ], + }), + }); + const mgr = new BackgroundManager(ctx); + const recovered = await mgr.recoverTasks(); + expect(recovered).toBe(1); + const task = mgr.getTask('bg_abc123'); + expect(task).toBeDefined(); + expect(task!.agent).toBe('scout'); + expect(task!.sessionId).toBe('sess_1'); + expect(task!.status).toBe('running'); + }); + + it('skips sessions without bg_ prefixed taskId', async () => { + const ctx = createMockCtx({ + sessionList: async () => ({ + data: [ + { + id: 'sess_1', + title: JSON.stringify({ taskId: 'not_a_bg_task' }), + status: { type: 'idle' }, + }, + ], + }), + }); + const mgr = new BackgroundManager(ctx); + const recovered = await mgr.recoverTasks(); + expect(recovered).toBe(0); + }); + }); + + describe('refreshStatuses', () => { + it('does not throw when session.children returns an empty object instead of an array', async () => { + const ctx = createMockCtx({ + sessionChildren: async () => ({ data: {} }), + }); + const mgr = new BackgroundManager(ctx); + + const taskMeta = JSON.stringify({ + taskId: 'bg_test1', + agent: 'scout', + description: 'test', + }); + const listCtx = createMockCtx({ + sessionList: async () => ({ + data: [ + { + id: 'sess_1', + title: taskMeta, + parentID: 'parent_1', + status: { type: 'running' }, + }, + ], + }), + }); + const listMgr = new BackgroundManager(listCtx); + await listMgr.recoverTasks(); + + const results = await mgr.refreshStatuses(); + expect(results).toBeDefined(); + }); + }); + }); });