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

  1. Update the plugin to a build matching the installed Codex Desktop version (the item schema is pinned per build).
  2. 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`).
  3. As a last resort, skip items without IDs (`continue`) so history still renders, accepting possible dedup loss.
  4. 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

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


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)