BigPizzaV3/CodexPlusPlus · error · Error

Message without an ID requires a stable fallback ID

Error message

Message without an ID requires a stable fallback ID

What it means

parseMessage derives a message id from the record's payload id or, when absent, from a caller-supplied fallbackId (string or function of the text). If neither yields a truthy id, it throws 'Message without an ID requires a stable fallback ID', because graph nodes need stable, deduplicatable ids across refreshes.

Solutions

  1. Always pass a stable fallbackId when calling parseMessage, e.g. `message-${threadId}-line-${i}` as graphFromExport does
  2. Pass a function fallback keyed on text (or text hash) so ids are stable across refreshes
  3. Skip records without ids instead of parsing them when ids aren't needed
  4. Check the codex client version if payload ids disappeared from rollout records

Example fix

// before
const m = parseMessage(record);
// after
const m = parseMessage(record, (text) => `msg-${threadId}-${hash(text)}`);
Defensive patterns

Strategy: validation

Validate before calling

const hasId = (rec) => Boolean(rec?.payload?.id) || typeof fallbackId === 'function' || (typeof fallbackId === 'string' && fallbackId);
if (!hasId(record)) throw new Error('Message without an ID requires a stable fallback ID');

Type guard

const canParse = (rec, fallbackId) => rec?.payload?.id || (typeof fallbackId==='function' && typeof fallbackId('')==='string' && fallbackId('')) || (typeof fallbackId==='string' && fallbackId);

Try / catch

try { const m = parseMessage(record, fallbackId); } catch (e) { if (String(e.message).includes('fallback ID')) { console.warn('skipping id-less record'); return null; } throw e; }

Prevention

When it happens

Trigger: Parsing a rollout record whose payload has no id field while the caller passed fallbackId=null/undefined/'' — e.g. parseMessage(r) called without the second argument, or a fallbackId function returning empty for empty text.

Common situations: New codex rollout record types lacking payload ids after a client update; hand-written tests parsing raw records without a fallback; fallback function returning '' for messages that reduce to empty text after citation stripping.

Understand the failure class

Background: "missing required argument" and "the following required arguments were not provided": what required-argument errors mean and how to fix them — this error's family across 20 libraries.

Related errors


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

Appendix: source

Thrown at tools/conversation-canvas/model.mjs:12

export function parseMessage(record, fallbackId) {
  const p = record.payload;
  if (record.type !== 'response_item' || p?.type !== 'message' || !['user','assistant'].includes(p.role)) return null;
  let text = (p.content || []).map(c => c.text || '').join('\n').trim();
  if (!text || /^<(recommended_plugins|environment_context)/.test(text) || text.startsWith('# AGENTS.md instructions')) return null;
  if (text.startsWith('<send_user_message_question_reply>')) {
    try { text = JSON.parse(text.replace(/<\/?send_user_message_question_reply>/g,'').trim()).map(a=>a.answer).join('\n'); } catch { return null; }
  }
  text = text.replace(/<oai-mem-citation>[\s\S]*?<\/oai-mem-citation>/g,'').trim();
  // Callers supply a stable fallback; native Codex message IDs take precedence.
  const id = p.id || (typeof fallbackId==='function'?fallbackId(text):fallbackId);
  if (!id) throw new Error('Message without an ID requires a stable fallback ID');
  return {id, text, role:p.role, phase:p.phase || '', timestamp:record.timestamp, ordinal:record.ordinal, turnId:p.internal_chat_message_metadata_passthrough?.turn_id || null};
}

export function excerpt(text, length=46) {
  return text.replace(/```[\s\S]*?```/g,'').replace(/[*#`]/g,'').replace(/\s+/g,' ').trim().slice(0,length);
}

export function buildGraph(messages, annotations={nodes:[]}) {
  const ids = new Set(messages.map(m=>m.id));
  const nodes = (annotations.nodes || []).filter(n=>n.sources?.length && n.sources.every(id=>ids.has(id))).map(n=>({...n,summaryKind:'已整理'}));
  const covered = new Set(nodes.flatMap(n=>n.sources));
  for(const id of annotations.incompleteSources||[])covered.delete(id);
  let previous = nodes.filter(n=>n.lane==='main').at(-1)?.id || null;
  for (const m of messages) {
    if (covered.has(m.id) || (m.role==='assistant' && m.phase!=='final_answer')) continue;
    nodes.push({id:`auto-${m.id}`,parent:annotations.schemaVersion===2?null:previous,lane:annotations.schemaVersion===2?'pending':'main',title:excerpt(m.text,34),summary:excerpt(m.text,160),description:m.text, status:'待整理',summaryKind:'原文摘录',sources:[m.id]});
    previous = `auto-${m.id}`;
  }

View on GitHub (pinned to b1ed92e5e4)