From 28df133dcd3a51601454044798eec6b6ee28b237 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Mon, 7 Sep 2026 03:01:26 +0000 Subject: [PATCH] fix(agent): guard undefined availableCommands in useAcpSlashCommands The cached-meta response can omit `availableCommands` entirely (a live ACP session entry that hasn't populated the field yet is serialized without the key), even though the response type declares it non-optional. `setCommands(response.availableCommands)` then set `commands` to `undefined`, and a later `.length` read crashed CanvasPage with an uncaught TypeError and no error boundary, making the whole Space unopenable. Default to `[]` when the field is missing, and add a regression test covering a response without `availableCommands`. Fixes #164 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/hooks/useAcpSlashCommands.test.tsx | 27 +++++++++++++++++++ apps/web/src/hooks/useAcpSlashCommands.ts | 7 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useAcpSlashCommands.test.tsx b/apps/web/src/hooks/useAcpSlashCommands.test.tsx index 2aef1e58e..6fa4cab51 100644 --- a/apps/web/src/hooks/useAcpSlashCommands.test.tsx +++ b/apps/web/src/hooks/useAcpSlashCommands.test.tsx @@ -79,6 +79,33 @@ describe('useAcpSlashCommands', () => { expect(container?.textContent).toBe(''); }); + it('falls back to an empty list when the response omits availableCommands', async () => { + // A live ACP session entry that hasn't populated `availableCommands` yet + // is serialized without the field entirely (JSON drops `undefined`), + // even though the response type declares it non-optional. Regression + // test for #164: this must not leave `commands` as `undefined`. + apiMocks.getCached.mockResolvedValue({ + source: 'thread', + commandsUpdatedAt: 0, + sessionMeta: { + availableModes: [], + currentModeId: null, + availableModels: [], + currentModelId: null, + configOptions: [], + selections: {}, + sessionInfo: null, + usage: null, + updatedAt: 0, + }, + }); + + await renderHarness(); + + expect(apiMocks.getCached).toHaveBeenCalledOnce(); + expect(container?.textContent).toBe(''); + }); + it('refreshes the GET cache on later slash-menu intent', async () => { apiMocks.getCached .mockResolvedValueOnce({ diff --git a/apps/web/src/hooks/useAcpSlashCommands.ts b/apps/web/src/hooks/useAcpSlashCommands.ts index 77b24262e..f53e34d2d 100644 --- a/apps/web/src/hooks/useAcpSlashCommands.ts +++ b/apps/web/src/hooks/useAcpSlashCommands.ts @@ -70,7 +70,12 @@ export function useAcpSlashCommands({ profileId, ); if (!isCurrent()) return; - setCommands(response.availableCommands); + // `availableCommands` is dropped from the JSON body entirely when the + // server's live session entry hasn't populated it yet, so `response` + // can arrive without the field despite the response type saying + // otherwise. Falling back to `[]` keeps `commands` an array so every + // downstream `.length` read stays safe. + setCommands(response.availableCommands ?? []); setError(null); lastFetchedAtRef.current = Date.now(); } catch (value) {