BigPizzaV3/CodexPlusPlus · error · Error

历史消息分页游标重复

Error message

历史消息分页游标重复

What it means

While fetching all items of a historical turn, the script loops 'thread/items/list' with a cursor and tracks every cursor seen in a Set; if the server returns a nextCursor equal to one already processed, the loop would be infinite, so it throws this guard error.

Solutions

  1. Update canvas.user.js to match the installed Codex version.
  2. Retry after reloading the thread — if the cursor cycle was transient, a fresh pagination run may succeed.
  3. Clear/rebuild the local thread cache entry (`${key}:partial`) so pagination restarts from a null cursor.
  4. Patch the loop to reset itemCursor to null (or bail gracefully) instead of throwing when a cycle is detected.

Example fix

// before
if(cursors.has(itemCursor))throw Error('历史消息分页游标重复');
// after
if(cursors.has(itemCursor)){console.warn('cursor cycle detected, stopping');itemCursor=null;continue;}
Defensive patterns

Strategy: retry

Validate before calling

// no pre-call check possible; guard is on server-returned cursor
cursors=new Set();

Type guard

const isFreshCursor=(c,seen)=>c==null||!seen.has(c);

Try / catch

try{await importTurnItems(turn);}catch(e){if(e.message==='历史消息分页游标重复'){cache.delete(`${key}:partial`);return importTurnItems(turn);}throw e;}

Prevention

When it happens

Trigger: A 'thread/items/list' response whose nextCursor points back to an already-used cursor value (including a repeated non-null cursor), producing a pagination cycle for that turn.

Common situations: Codex backend pagination bug after version upgrade; corrupted thread store returning stale cursors; transient server state during concurrent writes to the same thread.

Related errors


AI-assisted analysis of BigPizzaV3/CodexPlusPlus@b1ed92e5e4 (2026-09-19). Data as JSON: /api/errors/04a65ab291e0aa95. Report an issue: GitHub.

Appendix: source

Thrown at tools/conversation-canvas/public/canvas.user.js:425

    for(const turn of page.data){
      if(typeof turn.id!=='string')throw Error('历史轮次缺少 ID');
      if(!turnIds.has(turn.id)){turnIds.add(turn.id);turns.push(turn);}
    }
    cursor=page.nextCursor??null;
    onProgress(`正在读取轮次目录 · ${turns.length} 轮…`);
  }while(cursor!==null);
  const messages=[],seen=new Set();
  for(const [index,turn] of turns.entries()){
    signal?.throwIfAborted();onProgress(`正在读取历史 ${index+1}/${turns.length}…`);
    const key=`${threadId}:${turn.id}`,cached=await cache.get(key);
    let parsed;
    if(cached&&turn.status==='completed')parsed=cached;
    else{
      const partial=turn.status==='completed'?await cache.get(`${key}:partial`):null;
      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);

View on GitHub (pinned to b1ed92e5e4)