BigPizzaV3/CodexPlusPlus · error
历史消息缺少稳定 ID
Error message
历史消息缺少稳定 ID
What it means
For each row of type `userMessage` or `agentMessage`, readNativeHistory requires `item.id` to be a string because it is used as the stable message key for deduplication and cache identity. If the native API returns a message item without a string `id` (missing, null, or numeric), the plugin throws rather than generating unstable IDs that would break caching and dedup.
Solutions
- Update the plugin to a build matching the installed Codex Desktop version (the item schema is pinned per build).
- Inspect the failing row's JSON to find the new ID field name and map it in native-history.mjs (e.g. `item.id ?? item.itemId`).
- As a last resort, skip items without IDs (`continue`) so history still renders, accepting possible dedup loss.
- Check for a custom relay or wrapper stripping/renaming fields and fix it server-side.
Example fix
// before
if(typeof item.id!=='string')throw Error('历史消息缺少稳定 ID');
// after
if(typeof item.id!=='string'){console.warn('item without stable id, skipped',item);continue;} Defensive patterns
Strategy: validation
Validate before calling
const usable=page.data.every(r=>{const it=r?.item??r;return !['userMessage','agentMessage'].includes(it?.type)||typeof it?.id==='string';});
Type guard
function hasStableId(item){return item!=null&&typeof item.id==='string'&&item.id.length>0;}
Try / catch
try{history=await readNativeHistory(send,threadId,cache);}catch(e){if(/缺少稳定 ID/.test(e.message))return degradeToUncachedHistory();throw e;}
Prevention
- Schema-check item rows (type + id) before processing; skip unknown/malformed rows.
- Update plugin and Codex Desktop in lockstep so item schemas match.
- Keep a fallback rendering path that tolerates ID-less items instead of aborting the whole history import.
When it happens
Trigger: A `thread/items/list` row of type userMessage/agentMessage lacks `item.id` (after unwrapping `row.item ?? row`) — typically because a Codex Desktop build changed the item schema (e.g. renamed `id` to `itemId` or moved the id into a nested object).
Common situations: Codex Desktop upgraded and renamed/relocated the item ID field; rows synthesized by a custom relay that omit IDs; locally-pending/unsent messages surfaced by the API without a server-assigned ID.
Understand the failure class
Background: "must not be empty", "cannot be empty" — required-field validation errors across open-source libraries — this error's family across 41 libraries.
Related errors
- 整理结果节点 ID 无效
- 历史消息接口返回格式无效
- API 未能生成本批整理结果
- API 输出达到长度上限,本批未提交;请使用输出容量更大的模型
- API 未返回有效的 choices[0].message.content,请确认兼容 Chat Completions
AI-assisted analysis of BigPizzaV3/CodexPlusPlus@b1ed92e5e4 (2026-09-19).
Data as JSON: /api/errors/9d130d826c703ac1.
Report an issue: GitHub.
Appendix: source
Thrown at tools/conversation-canvas/native-history.mjs:51
parsed=partial?.messages?.slice()||[];let itemCursor=partial?.cursor??null,limit=32;const cursors=new Set();
do{
signal?.throwIfAborted();
if(cursors.has(itemCursor))throw Error('历史消息分页游标重复');
let page;
for(;;){
try{page=await send('thread/items/list',{threadId,turnId:turn.id,cursor:itemCursor,limit,sortDirection:'asc'});break;}
catch(error){
if(limit>1&&/decoded message length too large/i.test(error.message)){limit=Math.max(1,Math.floor(limit/2));continue;}
throw error;
}
}
if(!Array.isArray(page?.data))throw Error('历史消息接口返回格式无效');
cursors.add(itemCursor);
for(const row of page.data){
if(row.turnId!=null&&row.turnId!==turn.id)throw Error('历史消息所属轮次不匹配');
const item=row.item??row;
if(!['userMessage','agentMessage'].includes(item.type))continue;
if(typeof item.id!=='string')throw Error('历史消息缺少稳定 ID');
const role=item.type==='userMessage'?'user':'assistant';
const content=role==='user'?(item.content??[]):[{text:item.text??''}];
const message=parseMessage({type:'response_item',payload:{type:'message',id:item.id,role,phase:role==='assistant'?(item.phase??'final_answer'):'',content}},item.id);
if(message)parsed.push({...message,turnId:turn.id,turnStatus:turn.status});
}
itemCursor=page.nextCursor??null;
onProgress(`正在读取历史 ${index+1}/${turns.length} · 已读 ${parsed.length} 条消息…`);
if(turn.status==='completed'&&itemCursor!==null)await cache.set(`${key}:partial`,{messages:parsed,cursor:itemCursor});
}while(itemCursor!==null);
if(turn.status==='completed'){await cache.set(key,parsed);await cache.delete(`${key}:partial`);}
}
// Older completed-turn caches contain text and IDs but no turn metadata.
// The current directory and cache key provide the authoritative owner.
for(const message of parsed)if(!seen.has(message.id)){seen.add(message.id);messages.push({...message,turnId:turn.id,turnStatus:turn.status});}
}
// Bound cross-task retention, without truncating the returned conversation.
while(cache.size>500)cache.delete(cache.keys().next().value);
return messages;View on GitHub (pinned to b1ed92e5e4)