Skip to content

tui: delivered inbox items can retain a pending label #59615

Open

Description

@kernel-oops

Description

A delivered inbox item can still show as pending when the client misses its delivery event. The client can also retain an optimistic pending item after a successful prompt submission if both inbox events are missed. Pending state should clear for consumed items without removing their message history or genuinely pending inputs.

The live report was observed in a customised downstream build. Its exact event sequence is not known; the steps below reproduce the client-state gap in a fixture, not the historical live sequence.

Related: #56748 describes a delivered image remaining as a queued card, but its trigger differs and a shared cause is unconfirmed.

Plugins

Unavailable for the observed terminal. The client fixture loads no plugins.

OpenCode version

Customised downstream build based on 2.1.4. The fixture uses public source at 59542dae3c882e4cf9b029c83de508fffd797487.

Steps to reproduce

In a mocked client fixture:

  1. Load a synthetic inbox item alongside genuinely pending inputs.
  2. Have the server consume the item but omit its delivery event to the client.
  3. Send an execution failure or success event and inspect the pending collection.

The consumed item remains pending on the pinned public source. Alternatively, submit an optimistic prompt, return a successful POST after consumption and omit both inbox events: the optimistic item remains pending. Three focused tests fail before the proposed change.

Screenshot

Unavailable; no screenshot or recording is included.

Operating System

Linux x86-64.

Terminal

Unavailable; the observed terminal was not captured.

Activity

  1. opencode-agent commented on Oct 27, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed fixture-based report! I think this is a duplicate of #56748. Its symptom differs, but the root cause worked out in the PRs for that issue seems to be the same client-state gap you describe. The optimistic row's outbox guard is only cleared by the inbox echo, an inbox read that finds the item, or a rollback. If the delivery events are missed, a consumed item stays pending. Image prompts in #56748 just widen the window.

    Possible duplicates and related items, ordered by likelihood:

    Your three focused tests could be useful on #58072, to check whether it covers both scenarios.

    The bot will close this issue as a duplicate in 1 day unless you reply explaining how your problem differs from #56748.

  2. kernel-oops commented on Oct 27, 2026

    @kernel-oops
    ContributorAuthor

    Thanks for looking at this. I tested the three focused cases against #58072 at 71c354b. Its new tests pass, but these cases still fail without a message-list refresh or a conflict/cancel action:

    • reconciles a missed inbox delivery at session.execution.failed while keeping pending inputs and the corresponding session.execution.succeeded case both retain the consumed msg_notice in the pending list.
    • reconciles a successful local admission when both inbox events were missed retains msg_local after the successful POST and an inbox refresh: expected ["msg_remote"], received ["msg_remote", "msg_local"].

    The same three fail on the pinned public base. I cannot establish the original live event sequence, so I cannot rule out overlap with #56748, but these fixture cases are not cleared by #58072's execution or admission paths. The tests and fix are on https://github.com/kernel-oops/opencode/tree/fix/pending-delivery-reconciliation if useful.

  3. Neko-Yukari commented on Nov 12, 2026

    @Neko-Yukari

    Additional deterministic reproduction on v2.2.7 (fedc6f208aa2bd54c1019227214568083f6380ad): an older inbox hydration response arriving after a fresh transcript response reintroduces an already-consumed item into pending state. This fixture needs no plugins, no optimistic prompt submission and no live delivery event.

    This may be useful alongside the missed-event cases above and the stuck-row symptom in #63345. I have reproduced this client race, but have not captured the exact event sequence of the original live TUI occurrence.

    Reproduction

    In an empty directory, install @opencode/[email protected] and [email protected] with Bun, save the following as race.test.ts, and run bun test race.test.ts. HTTP responses are controlled; the client implementation is the published package.

    import { expect, test } from 'bun:test';
    import { createRoot } from 'solid-js';
    import { createData } from '@opencode/client/solid';
    import { OpenCode } from '@opencode/client';
    
    for (const type of ['user', 'synthetic'] as const) {
      for (const id of ['msg_000000000001normal', 'msg_f8c54ac2fcf94c648ca2d4a637398993']) {
        test(`stale inbox after transcript: ${type} ${id}`, async () => {
          const sessionID = 'ses_probe';
          const payload = { text: 'Already delivered', ...(type === 'synthetic' ? { description: 'Notice' } : {}) };
          const item = { id, sessionID, type, delivery: 'steer', time: { created: 1 }, payload };
          const delivered = { id, type, ...payload, time: { created: 2 } };
          const assistant = { id: 'msg_000000000002next', type: 'assistant', content: [{ type: 'text', text: 'Answer' }], time: { created: 3 }, finish: 'stop' };
          const inbox = Promise.withResolvers<Response>();
          const api = OpenCode.make({
            baseUrl: 'http://opencode.local',
            fetch: async (input) => new URL(input instanceof Request ? input.url : String(input)).pathname.endsWith('/message')
              ? Response.json({ data: [assistant, delivered], cursor: {} })
              : inbox.promise,
          });
          const setup = createRoot(dispose => ({
            dispose,
            data: createData({ api: () => api, directory: '/project', event: { on: () => () => {}, listen: () => () => {} } }),
          }));
          try {
            const pendingRead = setup.data.session.pending.sync(sessionID);
            await setup.data.session.message.sync(sessionID);
            inbox.resolve(Response.json({ data: [item] }));
            await pendingRead;
            console.log({ type, id, pending: setup.data.session.input.list(sessionID), created: setup.data.session.message.get(sessionID, id)?.time.created });
            expect(setup.data.session.input.list(sessionID)).toEqual([]);
            expect(setup.data.session.message.get(sessionID, id)?.time.created).toBe(2);
          } finally {
            inbox.resolve(Response.json({ data: [] }));
            setup.dispose();
          }
        });
      }
    }

    Observed results

    The local matrix also includes two controls per combination: delivery events clear pending; inbox hydration completing before transcript hydration clears pending. Those 8 controls pass. All 4 stale-inbox cases fail, for user/synthetic inputs and both ID shapes.

    Example observed state:

    {"type":"user","id":"msg_000000000001normal","clientInputs":["msg_000000000001normal"],"clientTime":1,"rows":["msg_000000000002next","msg_000000000002next","msg_000000000001normal"]}

    The server transcript has the input at time 2 followed by the assistant at time 3. The late inbox response restores enqueue time 1 and marks the delivered input pending. Feeding that state into the unchanged v2.2.7 reduceSessionRows places the input after the assistant. The duplicate assistant ID in this diagnostic list comes from projecting separate assistant rows.

    Relevant source

    The upstream solid-inbox-race.test.ts baseline separately passed 8/8 in the same environment (Windows, Bun 1.3.12). No product code was changed for this reproduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions