{"record":{"id":"4bbdf7a52565affb","repo":"jackwener/OpenCLI","slug":"expected-threads-array-got-typeof-threads-con","errorCode":null,"errorMessage":"expected threads array, got ${typeof threads} (contract drift?)","messagePattern":"expected threads array, got (.+?) \\(contract drift\\?\\)","errorType":"exception","errorClass":"CommandExecutionError","httpStatus":null,"severity":"error","filePath":"clis/slock/thread-list.js","lineNumber":33,"sourceCode":"  browser: true,\n  siteSession: 'persistent',\n  args: [\n    { name: 'server', help: 'Override active server' },\n  ],\n  columns: ['threadChannelId', 'parentMessageId', 'parentChannelName', 'unreadCount', 'replyCount', 'lastReplyAt'],\n  func: async (page, kwargs) => {\n    await page.goto(SLOCK_HOME_URL);\n    const snippet = buildFetchSnippet({\n      method: 'GET',\n      path: '/channels/threads/followed',\n      serverScoped: true,\n      serverIdOverride: kwargs.server,\n    });\n    const result = await page.evaluate(`(async () => { ${snippet} })()`);\n    const data = dispatchEvaluateResult(result);\n    const threads = Array.isArray(data) ? data : (data.threads || []);\n    if (!Array.isArray(threads)) {\n      throw new CommandExecutionError(`expected threads array, got ${typeof threads} (contract drift?)`);\n    }\n    return threads.map((t) => ({\n      threadChannelId: t.threadChannelId ?? t.id ?? '',\n      parentMessageId: t.parentMessageId ?? '',\n      parentChannelName: t.parentChannelName ?? '',\n      unreadCount: typeof t.unreadCount === 'number' ? t.unreadCount : 0,\n      replyCount: typeof t.replyCount === 'number' ? t.replyCount : null,\n      lastReplyAt: t.lastReplyAt ?? null,\n    }));\n  },\n});\n","sourceCodeStart":15,"sourceCodeEnd":45,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/slock/thread-list.js#L15-L45","documentation":"thread-list normalizes the API response with `Array.isArray(data) ? data : (data.threads || [])`; this throw fires when the result is still not an array — meaning `data` was a non-array object without `threads` or a primitive. The CLI treats it as a response contract drift and refuses to map rows.","triggerScenarios":"GET threads returns an envelope the CLI doesn't know ({data:[...]}, {items:[...]}), an error object that passed dispatchEvaluateResult, or a string like \"ok\" instead of a JSON array.","commonSituations":"API version change renaming the `threads` key; a gateway error page served with 200; hitting the wrong endpoint path after a base-URL change.","solutions":["Log `data` (and typeof) to identify the actual envelope shape.","Update dispatchEvaluateResult/snippet to unwrap the new envelope (e.g. `data.threads ?? data.items ?? data.data`).","Pin matching client/server versions.","Check the API base URL and auth — a redirect or error body can masquerade as a success payload."],"exampleFix":"// before\nconst threads = Array.isArray(data) ? data : (data.threads || []);\n// after\nconst threads = Array.isArray(data) ? data : (data.threads ?? data.items ?? data.data ?? []);","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"function isThreadsEnvelope(d) { return Array.isArray(d) || Array.isArray(d?.threads); }","tryCatchPattern":"try {\n  const threads = await cli.run(['thread-list']);\n} catch (e) {\n  if (String(e.message).includes('expected threads array')) {\n    console.error('unexpected /threads envelope — check API version and raw payload');\n  } else throw e;\n}","preventionTips":["Pin client/server versions","Unwrap new envelope shapes (data/items/rows) proactively","Assert response shape in integration tests"],"tags":["contract-drift","type-mismatch","threads"],"backgroundTag":"api-response-contract-drift","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}