Skip to content

BUG: Dual source of truth for in-flight message ID — currentMsgId and inflightId can disagree #90

Description

@devinoldenburg

Location

plugins/tps-meter.tsx:112,143,233,243

Problem

The in-flight message (currently streaming message) is tracked via two independent mechanisms that can diverge:

Mechanism 1 — Mutable variable (currentMsgId, line 112):

let currentMsgId = null;
// In onPart handler (line 112):
currentMsgId = part.messageID;
// Cleared in onMessage handler when message completes (line 143):
currentMsgId = null;

Mechanism 2 — Derived from message list (inflightId, line 233):

// In createMemo (line 228-234):
let inflightId = null;
for (let i = messages.length - 1; i >= 0; i--) {
  const m = messages[i];
  if (!isAssistant(m)) continue;
  if (!m.time?.completed) {
    inflightId = m.id;
    break;
  }
}

The fallback on line 243:

const inflight = (inflightId && timers.get(inflightId)) || (currentMsgId && timers.get(currentMsgId)) || null;

Divergence Window

There is a transient window where the two sources disagree:

  1. currentMsgId is set synchronously in onPart (immediately when a stream chunk arrives)
  2. inflightId is derived from props.api.state.session.messages() — a reactive call that may reflect state slightly before or after the event that triggered bump()

If the reactive message list lags behind the event stream (e.g., a message.part.updated event fires but the message list still shows the previous message as in-flight), inflightId points to the wrong message. The fallback || picks whichever exists, masking the divergence silently.

Concrete Scenario

  1. Message A completes → onMessage fires → currentMsgId cleared to null
  2. Message B starts streaming — first message.part.updated fires → currentMsgId = B
  3. bump() triggers createMemo re-evaluation
  4. messages list still shows Message A (with time.completed) as the last assistant message — Message B hasn't appeared in the list yet
  5. inflightId scanning loop finds all messages completed → inflightId = null
  6. Fallback kicks in: currentMsgId = B → picks the right timer

This happens to work because the fallback exists, but the dual tracking means the code has two paths to the same answer, with no validation that they agree.

Why This Matters

If the fallback were ever removed or reordered, or if both mechanisms returned different but valid-looking results (e.g., currentMsgId points to a message that completed but wasn't cleared because the message.updated event fired before message.part.updated — see issue #27), the view would show wrong live TPS data.

Expected Behavior

Choose one source of truth. Since currentMsgId is derived from events and inflightId is derived from the reactive message list, either:

  • Event-driven: Use only currentMsgId, remove the message-list scanning
  • Reactive-driven: Use only inflightId, remove currentMsgId

The event-driven approach is more responsive. Remove the message-list scan for in-flight detection and trust the event-derived state.

Severity

Low — the fallback prevents visible bugs, but the dual-source architecture is fragile and masks future regressions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions