{"record":{"id":"435d79271eef5125","repo":"tinyhumansai/openhuman","slug":"context-returned-an-invalid-response-shape","errorCode":null,"errorMessage":"${context} returned an invalid response shape","messagePattern":"(.+?) returned an invalid response shape","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/src/services/api/channelConnectionsApi.ts","lineNumber":62,"sourceCode":"function asRecord(value: unknown): Record<string, unknown> | null {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) {\n    return null;\n  }\n  return value as Record<string, unknown>;\n}\n\nfunction unwrapCliEnvelope<T>(payload: unknown): T {\n  const record = asRecord(payload);\n  if (record && 'result' in record && 'logs' in record && Array.isArray(record.logs)) {\n    return record.result as T;\n  }\n  return payload as T;\n}\n\nfunction expectArray<T>(payload: unknown, context: string): T[] {\n  const unwrapped = unwrapCliEnvelope<unknown>(payload);\n  if (!Array.isArray(unwrapped)) {\n    throw new Error(`${context} returned an invalid response shape`);\n  }\n  return unwrapped as T[];\n}\n\nfunction expectObject<T extends object>(payload: unknown, context: string): T {\n  const unwrapped = unwrapCliEnvelope<unknown>(payload);\n  const record = asRecord(unwrapped);\n  if (!record) {\n    throw new Error(`${context} returned an invalid response shape`);\n  }\n  return record as T;\n}\n\nfunction expectDiscordLinkStart(payload: unknown): DiscordLinkStartResult {\n  const record = expectObject<Record<string, unknown>>(payload, 'Discord link start');\n  if (typeof record.linkToken !== 'string' || !record.linkToken) {\n    throw new Error('Discord link start response missing required string field: linkToken');\n  }","sourceCodeStart":44,"sourceCodeEnd":80,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/a221052e0df5b1f7598fceba7329fd1af95d6699/app/src/services/api/channelConnectionsApi.ts#L44-L80","documentation":"expectArray unwrapped the payload (first stripping a CLI envelope { result, logs } if present) and the value was not an Array. The context string names the operation, e.g. 'Channel definitions', 'Channel status', 'Discord guild list', 'Discord channel list'. It marks a contract violation between the core/CLI and this client.","triggerScenarios":"channelConnectionsApi.listChannels()/status()/listDiscordGuilds()/listDiscordChannels() receiving an object or string instead of an array — e.g. an error envelope { error: ... } passed through as success, a handler change returning { channels: [...] } instead of a bare array, or a legacy CLI path emitting a scalar.","commonSituations":"Version skew after a core-side response refactor; a CLI-in-the-middle returning its envelope in a shape unwrapCliEnvelope doesn't recognize ({ result, logs } with logs non-array bypasses the unwrap); mocks in tests returning wrapped objects; error payloads not surfaced as exceptions upstream.","solutions":["Log the raw payload at the failure site and compare it with the core handler's actual return shape for the named operation","Restart/update the core so its response shape matches what this client version expects","If you control the mock/test server, return the unwrapped array (or { result: [...], logs: [] })","If the core legitimately changed, update this client's unwrapping/expectation to the new contract"],"exampleFix":"// before (mock/test server route)\napp.get('/rpc', () => jsonResponse({ channels: [/* ... */] }));\n\n// after\napp.get('/rpc', () => jsonResponse([/* ... */]));\n// or a CLI-shaped envelope the helper understands:\napp.get('/rpc', () => jsonResponse({ result: [/* ... */], logs: [] }));","handlingStrategy":"type-guard","validationCode":"const raw = await callCoreRpc<unknown>(params);\nconst isUsableArray = Array.isArray(unwrapCliEnvelope(raw));\nif (!isUsableArray) logPayloadForContractDiff(raw);","typeGuard":"const isArrayOf = <T>(v: unknown): v is T[] => Array.isArray(unwrapCliEnvelope(v));\n\nfunction unwrapCliEnvelope<T>(payload: unknown): T {\n  const record = typeof payload === 'object' && payload !== null ? payload as Record<string, unknown> : null;\n  if (record && 'result' in record && 'logs' in record && Array.isArray(record.logs)) {\n    return record.result as T;\n  }\n  return payload as T;\n}","tryCatchPattern":"try { const defs = await channelConnectionsApi.listChannels(); }\ncatch (e) {\n  if (String((e as Error).message).includes('invalid response shape')) {\n    logPayloadAndCoreVersion(); showError('Channel data unavailable — restart the app and retry.');\n  } else throw e;\n}","preventionTips":["Log the unwrapped payload whenever a shape error fires — the diff against the handler is the fix","Keep core and client versions locked together in releases","Make test mocks return the bare array or the { result, logs } envelope exactly"],"tags":["response-shape","rpc","channels","contract"],"backgroundTag":null,"analyzedSha":"a221052e0df5b1f7598fceba7329fd1af95d6699","analyzedAt":"2026-08-16T12:47:06.542Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}