Skip to content

[Bug]: SwiftUI shows a turn's later tool calls above assistant text that came before them #14695

Description

@saphid

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/swift-ios (SwiftUI mobile). This option isn't in the dropdown; this is not React Native apps/mobile.

Steps to reproduce

  1. Pair the SwiftUI app to a server, and open a project with a README.md and a few files under apps/.
  2. Start a new task (here: Claude · Claude Sonnet 5.5) with this prompt:

    Work in two separate steps. Before each step, write one short sentence saying what you are about to do. Step 1: read README.md and tell me its first heading. Step 2: list the files under apps/ and tell me how many files there are. Finish with a one-line summary.

  3. Watch the thread while the turn runs, then expand the Work log row after it settles.

The server recorded this order (scratch database, UTC, 2 Oct 2026):

Time Event
00:08:18.060 assistant: "Step 1: I'll read README.md…"
00:08:18.060–18.744 Read README.md (tool.started → tool.completed)
00:08:19.769 assistant: "The first heading … is "Demo Notes". Step 2: I'll list the files under apps/…"
00:08:19.772–21.027 Bash: find …/apps -type f (tool.started → tool.completed)
00:08:22.033 assistant: "There are 3 files…" and the summary

Expected behavior

Tool activity appears where it happened relative to the assistant text: the find call appears after "Step 2: I'll list the files…", not before it.

Actual behavior

Both tool calls are folded into one Work log · 2 row directly under "Step 1". The step 2 find call is therefore shown above the "Step 2: I'll list the files…" text that preceded it, both live and after the turn settles.

On SwiftUI, every tool event in a turn goes into one work-log row keyed by turnId:

So text1 → toolA → text2 → toolB renders as text1, [A+B], text2.

Web groups consecutive work entries and ends a group at any non-work entry (MessagesTimeline.logic.ts#L1220-L1260). React Native builds activity-group rows separately (threadActivity.ts#L2182). I have not captured web or React Native screenshots of this thread.

Question for maintainers: what is the intended SwiftUI grouping? No fix is proposed here until that is triaged.

  • (a) End a work-log group at assistant text, notices and turn boundaries, like web, and keep consecutive tool calls in one collapsed row; or
  • (b) Keep one row per turn, but position it so later tool calls don't appear above earlier text; or
  • (c) The current per-turn row is the intended SwiftUI design.

Earlier, unmerged attempts in this area were closed PRs #10766 (chronology fix; it split rows per tool call) and #10767 (consecutive-call grouping with relative ages). Neither is being re-proposed. Timestamps and ages on rows are a separate topic and not part of this report.

Impact

Minor bug or occasional failure. The transcript misstates the order in which the agent worked whenever it writes text between tool calls.

Version or commit

SwiftUI app: a Debug build of t3code/rebuild-mobile-app-swift @ 285d2ab80c, from a clean tree. I reused an existing build rather than building the current target.

Current target: bb01adbf90. The relevant code is unchanged from that build: from makeDetailDelta to the end of NativeFeatureClient.swift, the only differences are one added newProjectsRoot mapping line and an afterSequence replay-skip in the reducer. Neither touches the turnId grouping, the first-event anchor or the sort. ThreadDetailView.swift differs only by an added "Restart agent session" menu item.

Server: the same repository revision (node apps/server/src/bin.ts serve), with an empty scratch home and a scratch project.

Environment

  • iOS 26.5 Simulator, iPhone 17 Pro, light appearance.
  • Provider: Claude · Claude Sonnet 5.5 (High).
  • Local scratch server on 127.0.0.1.

Logs or stack traces

N/A. The event order above comes from the server's projection_thread_messages and projection_thread_activities tables for that thread.

Screenshots, recordings, or supporting files

Settled, collapsed. One Work log · 2 row under "Step 1", above "Step 2: I'll list the files…":

Settled transcript: one Work log · 2 row under Step 1, above the Step 2 text

Settled, expanded. The row contains both the step 1 Read and the step 2 find, above the Step 2 text:

Expanded Work log · 2: Read README.md and Bash find apps, both above the Step 2 text

Live turn. Mid-turn, Work log · 1 / Command run (the step 2 find) is already above "Step 2: I'll list the files…", then becomes Work log · 2:

Live turn: the Command run joins the work-log row above the Step 2 text

About the GIF. Its frames come from an unedited simctl recordVideo capture of the whole turn (295 s, H.264). Frames were sampled at 4 fps and play at 0.25 s each, with the last frame held. The source has irregular timestamps, so the GIF does not show exact timing.

How it was driven. A GPT-6 Astra agent operated the Simulator UI: it chose the model, typed the prompt, and captured screenshots and the recording. The T3 Device panel listed and opened the device, but its UI automation failed twice (no active session, then a daemon timeout), so the agent used the AXe Simulator CLI. Earlier, Claude Opus 5.5 paired the app and added the scratch project with the same CLI. The agent turn itself was real, not a fixture.

Not checked: a physical device, the current-target build itself, and web or React Native captures of the same thread.


Reported with Claude Opus 5.5 (Claude Code), with Simulator UI driven by GPT-6 Astra (Codex) via T3.

Activity

  1. juliusmarminge commented on Oct 2, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Triage

    Thanks for the event table and the live and settled captures, @saphid! This is a real bug in the experimental SwiftUI client. I confirmed it on t3code/rebuild-mobile-app-swift at bb01adbf90, the branch tip (open PR #5178). collapsedWorkLogs, the live turnId grouping in applyActivityMutation, and the first-event timestamp in NativeWorkLogAccumulator are all unchanged from your build 285d2ab80c.

    To answer your question: it's (a). A work-log group should end at assistant text and at other non-tool rows, while consecutive tool calls stay together in one collapsed row. A new turn already starts a new row. Neither (b) nor (c) is the intended behavior.

    What happens today

    Every tool event in a turn goes into a single work-log-<turnId> row. The snapshot path groups by turnId in collapsedWorkLogs, and the live path uses the same key. The row's date comes from the first tool event and doesn't change when later tools arrive. The transcript is sorted by that date, so text → tool → text → tool shows up as text, [both tools], text. Your Step 2 find appears above the Step 2 sentence because the row is still dated from the Step 1 Read. That's true both live and after the turn settles.

    The comment above collapsedWorkLogs is about keeping rows a bounded size on long turns. It doesn't mean tool calls on opposite sides of assistant text should share one bubble. Since a single timestamp can't sit both before and after that text, re-dating the row to the last tool or the end of the turn would only put a different call on the wrong side.

    Intended behavior

    • Consecutive tool calls, with only lifecycle updates between them, stay in one collapsed Work log row.
    • A visible assistant message ends that row. The next tool starts a new row after the message, dated from that tool.
    • A notice ends the row the same way. Errors, runtime warnings, and context compaction already get their own timeline rows (NativeActivityNotice), and later tools shouldn't stay bundled above them.
    • A new turn starts a new row, which already works.
    • Expanding a row shows only the calls in that group, in order.

    Web already splits groups at assistant text (isActivityEntry and the consecutive-work loop in MessagesTimeline.logic.ts), and React Native's grouping stops at a non-reasoning message too (activityRunTurnId in threadActivity.ts). Settled web turns also fold earlier activity behind "Worked for…", but this report isn't asking for that.

    Fix scope

    Both paths need the new boundary. The snapshot rebuild groups activities without looking at messages, and the live updater keeps adding to work-log-<turnId> after an assistant message arrives.

    Still out of scope, as your report says:

    • One row per tool call. Consecutive calls with no text between them should stay together.
    • Relative ages, the header receipt, and the last-message age line.

    #10766 (closed PR) was closed under the one problem rule because it mixed the receipt line into the ordering change. #10767 (closed PR) was closed under prior approval because there was no triaged direction for the grouping-plus-ages presentation. This issue settles the grouping boundary only. It doesn't approve the age display, and it doesn't reopen those PRs as they stand.

    A focused fix should show this same two-step prompt, both live and settled. The Read should appear under the Step 1 sentence, the find should appear under the Step 2 sentence, and two calls with no assistant text between them should still share one row.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions