Skip to content

fix(cli,shared): render engine-opened agent turns as their own history entries - #653

Draft
tommy0103 wants to merge 2 commits into
LodyAI:mainfrom
tommy0103:fix/engine-turn-entries
Draft

tommy0103 wants to merge 2 commits into
LodyAI:mainfrom
tommy0103:fix/engine-turn-entries

Conversation

@tommy0103

@tommy0103 tommy0103 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Related issue

Closes #640

Companion PR: #654 must land together with this one. It carries the
lifecycle guards the codex review rounds identified (fork/edit-resend
suppression of auto: entries, ACP exit-path marker cleanup, engine-turn
Stop routing, replay-window bypass, monitor-state coverage). The split
exists only to keep this PR under the community 1000-line gate; review them
as one change and merge back-to-back. (An origin-divider UI was tried and
reverted — it breaks the stream index contract; tracked in #660.)

Problem / pressure

The Kimi engine opens turns behind the client's back — a cron fire, a task wake — and its ACP server forwards their content by design. Those updates carried no turn identity, so the history layer routed them into the last client turn's assistant entry (the never-expiring finalized-turn target built for stragglers), or dropped them when no client turn existed in memory. Observed in production sessions: the user turn's real reply folded into a collapsed "thinking process" while the cron turn's text became the visible answer; a session showing "completed" (executionState: idle) while an engine turn ran for 15+ minutes; the 20-min idle GC SIGKILLing the agent process mid-cron-turn; engine-turn output dropped after a daemon restart.

Summary

  • Wire identity (kimi ACP server, PR feat(acp-server): stamp engine-opened turns with a non-fork identity acp-extension-kimi#9, submodule pin bump included): engine-opened turns are stamped _meta.lody.turnId = auto:<n> (non-numeric, can never parse into a session/fork position) + _meta.lody.turnOrigin; a _meta.lody.turnEnded marker is emitted when such a turn ends.
  • Turn-aware grouping (@lody/shared history-apply): only auto:/turnOrigin-marked updates get turn-aware routing — owner entry → adopt only a delta-accepting entry → else create assistant:autonomous-<turnId>. Every other stamped id keeps the exact legacy last-wins restamping (claude per-message boundary uuids and codex collab child turns are boundaries inside one client turn). turnEnded finalizes the owning entry once. acpTurnOrigin is persisted on the entry.
  • Routing (apps/cli): marked updates always take a synthesized autonomous target — never the active/finalized client turn's — covering the no-target drop and rich content. An engine-turn activity marker (set on first marked update, cleared on end marker or ACP process termination — process-bounded, no wall clock) feeds hasActiveTurn (idle-GC guard) and upgrades live status unknownrunning.
  • UI: the chat stream renders an eyebrow divider ("Scheduled task" / "Background task") before engine-opened turns, since they have no user message.

Visual explanation

flowchart LR
  subgraph kimi [Kimi engine + ACP server]
    UT[user turn 40] --> T41[cron turn 41, origin=cron_job]
    T41 --> S["_meta.lody: turnId=auto:41, turnOrigin, turnEnded"]
  end
  S --> R{apps/cli enqueueACPUpdate}
  R -->|marked| AT[autonomous target<br/>assistant:autonomous-auto:41]
  R -->|unmarked| LT[active/finalized client target]
  AT --> HA{history-apply}
  LT --> HA
  HA -->|auto:/turnOrigin only| E1[own entry: adopt-if-accepting<br/>or create autonomous entry]
  HA -->|any other id| E2[legacy last-wins restamping<br/>claude uuids, codex collab]
  S --> M[engineTurn activity marker<br/>transient store]
  M -->|set| BUSY[live status running + GC guard]
  M -->|turnEnded / process exit| IDLE[clear]
  E1 --> UI[divider: Scheduled task<br/>+ own finished turn]
Loading

Before / after

Before After
Engine turn output appended to the previous user turn's entry; its text became the visible "answer", real reply folded User turn keeps its reply; engine turn renders as its own following turn
Engine-turn updates dropped after daemon restart Routed to assistant:autonomous-<turnId>; content survives
Engine turn entry never finalized (perpetual streaming) turnEnded marker finalizes it once
executionState: idle while the engine turn runs; idle GC kills the agent mid-turn Live status reads running; GC guard sees the engine turn; marker clears on end/process exit
No visual cue that a turn came from a cron fire Eyebrow divider labels the origin

Test plan

  • packages/shared: full suite 1133 passing, incl. new cases — engine-turn separation, cross-batch continuity, died-before-output adoption hole, end-marker finalization (once-only timing), claude multi-uuid last-wins, codex collab inline restamping.
  • apps/cli: full suite 2715 passing (3 failures reproduce on unmodified origin/main in this environment: two Claude credential-store probes, one macOS /var vs /private/var worktree-GC assertion) — new store cases for autonomous-target gating and engine-turn activity note/clear/replace.
  • packages/components: 16 passing, incl. divider emission on both incremental and cache-reuse paths. acp-server (submodule): 163 passing (1 pre-existing env e2e failure on base).
  • tsc clean in shared/cli/components/acp-server; prettier, oxlint, check-i18n, pnpm run docs check clean.
  • End-to-end on OSS desktop + locally built stamped runtime: cron turn after a user turn rendered as its own finished turn with origin divider; session presence read running during the engine turn, idle after; provider-500-empty-turn + adjacent cron fire no longer merges.
  • Skipped: replay/import consistency and cron-prompt surfacing (follow-ups in the issue); GC-kill repro used reasoning + logs, not a 20-min live wait.

Context handoff

Instructions for reviewing agents

  • Review focus: packages/shared/src/acp/history-apply.ts (the three-way turnId branch in apply() + ensureEntryForAcpTurn adoption condition + finalizeEntryOnce), apps/cli/src/lib/session-transient-store.ts (autonomousACPUpdateTargetFrom gating, engine-turn marker lifecycle), apps/cli/src/lib/message-handler.ts (enqueueACPUpdate target precedence), kimi acp-server/src/session.ts (stamp + end-marker emission).
  • Decisions to challenge: gating all new behavior on auto:/turnOrigin markers only (legacy path kept for every other stamped id); adoption allowed only on delta-accepting entries; engine-turn liveness bounded by ACP process termination rather than a wall clock; auto:<n> as the non-fork id format.
  • Plausible failures / evidence gaps: task-wake turns now also render separately (subagent UX change); a GC-killed engine turn never emits its end marker (unfinished entry, same as any crashed turn); replay/import does not classify cron fires (reload can surface cron XML as user messages — known follow-up); kimi stamping only lands with the next managed-runtime artifact, and the client side is untested against that artifact in production.

Authoring context

  • User goal / directives: Investigate and fix a session display bug where a user-message turn interrupted-adjacent to a cronjob fire collapses into a "thinking process"; user then directed: two turns should render separately, keep the blast radius kimi-only, verify end-to-end, file the issue and open the PRs.
  • Constraints / non-goals: No behavior change for claude/codex/pi/grok/deepseek or unstamped runtimes; fork-at-turn semantics untouched; no replay/import rework, no cron-prompt surfacing, no GC/lifecycle redesign beyond the marker-based guard.
  • Risk-bearing decisions: new history field acpTurnOrigin (additive Loro schema change); deterministic entry ids assistant:autonomous-<turnId>; non-numeric auto: ids can never resolve into session/fork positions; legacy last-wins restamping preserved byte-for-byte for all unmarked ids.
  • Destructive or irreversible behavior: none — history writes are additive; a duplicate end marker cannot move an entry's terminal timing; failed flush groups requeue with at-least-once semantics unchanged.
  • Deliberately not done or tested: cron-prompt user-message surfacing, replay/import origin classification, UI label for the cron prompt text, live GC-kill repro (20-min wait); each is recorded as a follow-up in [Bug] Engine-opened agent turns (cron fires, task wakes) merge into the previous user turn instead of rendering as their own turn #640.
  • Unknowns / confidence: high confidence in unit/replay-level correctness and the single-machine e2e; moderate on multi-turn subagent UX (wake turns separating) and on behavior before the stamped artifact ships (inert by design, asserted by tests but not observed in production).

Original user prompt

Show original prompt
现在似乎有个 bug,在 fusion-model 下有个 session,你会发现如果当 user message trigger 的一个轮次被 cronjob 打断,那么这个轮次会被折叠为思考过程,这个显示显然不是我们期望的,你去 check 一下

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3345e722a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const autonomousIndex = this.pushAssistantEntry(`assistant:autonomous-${turnId}`);
const autonomous = this.history[autonomousIndex];
if (autonomous) {
autonomous.acpTurnId = turnId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject autonomous IDs before fork or resend

A finished autonomous entry now carries acpTurnId: "auto:…", but the existing renderer enables Fork for any finished entry with an acpTurnId, SessionForkService forwards that value as forkSessionTurnId, and edit-and-resend likewise selects the nearest preceding assistant's acpTurnId. After any cron/task turn, forking that entry—or editing the next user turn—therefore sends a deliberately non-fork-position ID to Kimi and fails. Suppress autonomous entries in both selection paths, or otherwise reject them before dispatch.

AGENTS.md reference: apps/cli/src/session/AGENTS.md:L97-L103

Useful? React with 👍 / 👎.

);
// A dying ACP process ends any engine-opened turn it hosted: drop the
// activity marker so busy status and the idle-GC guard release the session.
this.onSessionTerminated((sessionId) => this.handler?.clearEngineTurnActivity(sessionId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear engine activity on the ACP child exit

When an engine turn's ACP process crashes before emitting turnEnded, this hook does not observe that process exit: onSessionTerminated is driven by the Session's terminated event from explicit Session.terminate(), while the unexpected ACP-child exit callback in session.ts only nulls agentProcess. The engineTurn marker consequently remains set indefinitely, so live status stays running and hasActiveTurn prevents idle GC until somebody explicitly terminates the whole Session. Clear the marker from the actual ACP process-exit path.

Useful? React with 👍 / 👎.

@tommy0103
tommy0103 force-pushed the fix/engine-turn-entries branch from c3345e7 to 6ae0425 Compare September 12, 2026 16:30
@tommy0103

Copy link
Copy Markdown
Contributor Author

Thanks — valid catch. Autonomous entries (auto: ids) are now suppressed in all four selection paths: the CLI edit-and-resend walk (session-edit-and-resend-service.ts), cloneHistoryThroughTurn (rejects an engine-opened fork source), the renderer edit-and-resend enablement (session-chat-interface.tsx), and the renderer fork gate (view.tsx). Regression tests cover both the backward walk skipping an autonomous entry (boundary resolves to the preceding real provider turn) and the fork-source rejection.

The fix landed in the stacked follow-up #654 (commit d27b19e) rather than this PR to keep #653 under the community 1000-line gate; behavior and tests are identical to having it here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ae0425086

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@@ -1 +1 @@
Subproject commit d3f218c27db5eb338e08698dd2fccb5de1f30cf6
Subproject commit 1c5acbc6a4aad173472faec46b006a3bc1fb4684

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ship the stamped Kimi runtime with this change

When this commit is released as-is, built-in Kimi still downloads the artifact pinned by apps/cli/src/agent/kimi-runtime-manifest.json, whose sourceCommit remains f255222..., rather than the newly referenced 1c5acbc... submodule code that emits turnId, turnOrigin, and turnEnded. Since managed-agent-runtime.ts constructs the production Kimi runtime exclusively from that manifest, installed users continue running the unstamped adapter and autonomous output is still merged into the previous turn or dropped after restart; package the new submodule revision and update the manifest version/checksum before shipping.

AGENTS.md reference: AGENTS.md:L44-L46

Useful? React with 👍 / 👎.

@tommy0103

Copy link
Copy Markdown
Contributor Author

Correct on the facts, and this sequencing is deliberate (also called out in this PR's Summary and in the linked decision note):

  • The manifest pin can only move after feat(acp-server): stamp engine-opened turns with a non-fork identity acp-extension-kimi#9 merges: the checksummed artifact must be built from the upstream-merged submodule revision, not from the fork commit this branch currently pins for development (1c5acbc6). Artifact production/publication to the R2 channel is an external distribution step I cannot perform from here.
  • Shipping this PR before that artifact lands is safe by construction: every new behavior is gated on the auto:/turnOrigin markers, which an unstamped runtime never emits, so installed users see byte-identical behavior (unit-tested: unstamped updates follow the exact legacy path). The bug simply stays unfixed for them until the artifact arrives — nothing breaks.

Follow-ups required after #9 merges, before this PR's behavior reaches users: (1) build + publish the new managed-runtime artifact, (2) bump kimi-runtime-manifest.json (version/sha256/sourceCommit), (3) repoint the submodule pin here to the upstream merge commit. Happy to do (2) and (3) in this PR or a fast follow-up, whichever the maintainer prefers.

…y entries

An agent engine can open turns of its own behind the client's back (a
kimi cron fire, a task wake). Their ACP updates carried no turn identity,
so the history layer appended them to the last client turn's assistant
entry: the real reply was folded away and the interrupting turn's text
became the visible answer, and after a daemon restart such updates were
dropped outright.

- history-apply: route turn-stamped updates by ownership — scoped to
  kimi-stamped turns only (auto: id or turnOrigin marker): adopt only a
  delta-accepting entry or create assistant:autonomous-<turnId>. Every
  other id keeps the exact legacy last-wins restamping (claude per-message
  uuids, codex collab child turns are boundaries inside one client turn).
  turnEnded finalizes the owning entry once; acpTurnOrigin persists.
- session-transient-store: route marked updates to a synthesized
  autonomous target even while a client turn owns the session; maintain an
  engine-turn activity marker (set on first marked update, cleared on end
  marker or ACP process termination) feeding hasActiveTurn (idle-GC guard)
  and live status unknown -> running.
- Bump the kimi submodule for the matching stamping (turnId auto:<n> +
  turnOrigin + turnEnded on engine-opened turns).

Model: kimi-code/kimi-for-coding
@tommy0103
tommy0103 force-pushed the fix/engine-turn-entries branch from 6ae0425 to 4567ff9 Compare September 12, 2026 18:09

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4567ff9ce6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1402 to +1403
if (isAutonomousTurnId(turnId) || readLodyTurnOrigin(update) !== undefined) {
const entryIndex = this.ensureEntryForAcpTurn(turnId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route Stop requests to engine-owned turns

While an autonomous cron/task turn is running, this branch creates an unfinished assistant entry, so resolveActiveAssistantTurnId returns its assistant:autonomous-auto:… entry ID and the Stop/interrupt controls pass that ID to requestSessionCancel. However, SessionExecutionService.cancelSession only interrupts IDs owned by getActiveTurnId, currentTurnBySession, or turnRuntimeBySession; engine-opened turns have none of those, so the request follows the stale-turn path, reports success, and never calls the agent client while the work continues. Add an engine-turn cancellation path or prevent these entries from being exposed as cancellable client turns.

Useful? React with 👍 / 👎.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Engine-opened agent turns (cron fires, task wakes) merge into the previous user turn instead of rendering as their own turn

1 participant