{"record":{"id":"8341290ab6c997fb","repo":"chenhg5/cc-connect","slug":"codex-app-server-resume-returned-empty-thread-id","errorCode":null,"errorMessage":"codex app-server resume returned empty thread id","messagePattern":"codex app-server resume returned empty thread id","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"agent/codex/appserver_session.go","lineNumber":335,"sourceCode":"\t}\n\tif err := s.notify(\"initialized\", nil); err != nil {\n\t\treturn fmt.Errorf(\"codex app-server initialized notify: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (s *appServerSession) ensureThread(resumeID string) error {\n\tif resumeID != \"\" && resumeID != core.ContinueSession {\n\t\tparams := s.threadRequestParams()\n\t\tparams[\"threadId\"] = resumeID\n\t\tparams[\"persistExtendedHistory\"] = true\n\n\t\tvar resp threadResumeResponse\n\t\tif err := s.request(\"thread/resume\", params, &resp); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif resp.Thread.ID == \"\" {\n\t\t\treturn fmt.Errorf(\"codex app-server resume returned empty thread id\")\n\t\t}\n\t\ts.applyThreadRuntimeState(resp.Cwd, resp.Model, resp.ReasoningEffort)\n\t\ts.threadID.Store(resp.Thread.ID)\n\t\tslog.Info(\"codex app-server thread resumed\", \"thread_id\", resp.Thread.ID)\n\t\treturn nil\n\t}\n\n\tvar resp threadStartResponse\n\tif err := s.request(\"thread/start\", s.threadRequestParams(), &resp); err != nil {\n\t\treturn err\n\t}\n\tif resp.Thread.ID == \"\" {\n\t\treturn fmt.Errorf(\"codex app-server start returned empty thread id\")\n\t}\n\ts.applyThreadRuntimeState(resp.Cwd, resp.Model, resp.ReasoningEffort)\n\ts.threadID.Store(resp.Thread.ID)\n\tslog.Info(\"codex app-server thread started\", \"thread_id\", resp.Thread.ID)\n\treturn nil","sourceCodeStart":317,"sourceCodeEnd":353,"githubUrl":"https://github.com/chenhg5/cc-connect/blob/4000b2338aa6e850c99df54f8b0ed6ed7460b401/agent/codex/appserver_session.go#L317-L353","documentation":"This error is returned when resuming an existing codex thread: the \"thread/resume\" JSON-RPC request succeeded but its response contained an empty thread id. The library treats a resume without an id as invalid because every subsequent turn must reference the thread id. It guards against protocol drift where the response shape changed (e.g. id moved to a different field).","triggerScenarios":"Calling Send/SendWithResume on a codex session with a non-empty resumeID; the app-server answers thread/resume but resp.Thread.ID is \"\" — either the server returned a malformed/degraded response or the client's threadResumeResponse struct no longer matches the server's JSON shape.","commonSituations":"Codex CLI upgrade changed the resume response schema (thread id relocated/renamed); resuming a thread id that the server silently cannot resolve and returns an empty object for; corrupted session store on the app-server side.","solutions":["Compare the actual thread/resume JSON against the threadResumeResponse struct; update the struct to the current codex protocol.","Log the raw response body on this error path to see the real shape.","Start a new thread (drop the stale resume id) if the server no longer knows it.","Pin/downgrade the codex CLI to a version matching the expected response schema.","Verify the resumeID being passed is a valid id previously returned by thread/start, not a user-supplied session label."],"exampleFix":"// before\ntype threadResumeResponse struct {\n    Thread struct{ ID string `json:\"id\"` } `json:\"thread\"`\n}\n// after — after inspecting the raw server response for schema drift:\ntype threadResumeResponse struct {\n    Thread struct {\n        ID string `json:\"id\"`\n    } `json:\"thread\"`\n    // fallback for servers that return the id at top level\n    ThreadID string `json:\"threadId\"`\n}\nfunc (r threadResumeResponse) id() string {\n    if r.Thread.ID != \"\" { return r.Thread.ID }\n    return r.ThreadID\n}","handlingStrategy":"type-guard","validationCode":"// Before resuming, verify the id is a thread id previously returned by the app-server\nif resumeID == \"\" || !isValidThreadID(resumeID) {\n    return errors.New(\"invalid or missing thread id for resume; start a new thread\")\n}","typeGuard":"func hasValidThread(resp threadResumeResponse) bool {\n    return resp.Thread.ID != \"\"\n}\n\nif !hasValidThread(resp) {\n    slog.Warn(\"thread/resume returned no id; starting fresh thread\")\n    return s.ensureThread(\"\")\n}","tryCatchPattern":"if err := session.Send(ctx, prompt); err != nil {\n    if strings.Contains(err.Error(), \"resume returned empty thread id\") {\n        // schema drift or stale id: fall back to a new thread\n        session, err = agent.StartSession(ctx, core.StartOptions{})\n    }\n    return err\n}","preventionTips":["Log raw JSON-RPC responses so schema drift is immediately visible.","Run integration tests against the pinned codex CLI version on every upgrade.","Treat empty ids as fall-back-to-new-thread rather than hard failure in user flows.","Never feed user-facing session labels in as thread ids."],"tags":["json-rpc","schema","resume","codex"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"4000b2338aa6e850c99df54f8b0ed6ed7460b401","analyzedAt":"2026-09-06T11:45:09.575Z","contentChangedAt":"2026-09-06T11:45:09.575Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}