Commit Graph
7 Commits
Author SHA1 Message Date
763f45acdd feat: add support for Kimi Code CLI and fixed some bugs (#659)
* Add Kimi agent support via ACP protocol

Add full integration for the Kimi Code CLI agent using the standard
Agent Client Protocol (ACP). Includes:

- kimi command and CLI registry wiring
- Local launcher spawning kimi directly
- Remote launcher with ACP stdio transport via AcpSdkBackend
- Session management with resume support
- Permission handler supporting all Kimi permission modes
- Terminal UI display component
- Runtime config resolving model from env and ~/.kimi/config.toml

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Fix Kimi ACP tool call input decoding on web

Kimi streams tool arguments as JSON text inside the content array
(e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler
now extracts input from three sources in priority order:

1. rawInput (Claude/Codex path)
2. kind + title fallback (Gemini path)
3. content JSON text (Kimi path)

Also handles:
- rawInput: null no longer blocks the kind+title fallback
- Title prefixes like \"Shell: free -h\" are stripped to extract args
- Stale placeholder inputs are re-derived when the title updates
- Normalized kind aliases (shell, run, read_file, write, etc.)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Add kimi support to web UI

* Fix some bugs

* fix(kimi): dedupe repeated tool_call display in terminal UI

* fix(web): keep tool block immutable so React detects input/state changes

* fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools

* fix(web): allow-for-session for ACP agents (kimi, cursor)

PermissionFooter treated all non-codex sessions as Claude, sending
Claude-specific acceptEdits/allowTools to ACP agents that don't
support them. Hub rejected acceptEdits for kimi, and the ACP
PermissionAdapter ignored allowTools.

- Only show 'allow all edits' for Claude sessions
- Send decision: approved_for_session for non-Claude ACP agents
- Update status display to check decision field

* fix(web): lookup subagent sidechains by tool-call id instead of msg id

* fix(web): don't trim newest messages when loading older history

fetchOlderMessages was using trimVisible(merged, 'prepend') which kept
the oldest 400 messages and dropped the newest ones. This caused:
1. Latest messages to disappear when user loaded older history
2. User to see no visible change when new old messages were drowned
   in the 400-message window.

Remove the incorrect trim so all fetched older messages are retained
alongside the current window. Subsequent ingestIncomingMessages
(append mode) will naturally keep the window bounded when new agent
messages arrive.

* fix(cli): route Kimi session resume to runKimi instead of runCursor

Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch
for it, so resuming a Kimi session fell through to the Cursor launcher.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env

createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR.
Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): bound message window on older loads with dedicated larger cap

fetchOlderMessages was keeping all messages unbounded, causing
sessionStorage bloat on repeated pagination. Reintroduce trimming
with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the
newest messages are still preserved for far longer than before.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline

tracer.ts sets sidechainId to the parent message id, and reducer.ts groups
by sidechainId. A prior commit changed reducerTimeline.ts to look up by
tool-call id (c.id), which broke sidechain attachment. Revert to msg.id
so the lookup matches the actual grouping key end-to-end.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): gate ACP title prefix stripping to known tool-kind labels

extractTitleArgument stripped at the first colon unconditionally,
corrupting commands/paths like curl http://localhost:3000 or
Windows paths. Now it only strips when the prefix normalizes to
the same tool kind as the event, verified via regex.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI

Kimi is an ACP-style agent that supports the abort decision, but
isCodexFamilyFlavor excluded it, so PermissionFooter rendered the
non-Codex Allow/Deny UI without the Abort button.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-05-22 10:08:14 +08:00
b06251bd53 fix: stream opencode reasoning updates (#661)
Emit throttled ACP reasoning snapshots with stable stream ids so OpenCode reasoning updates render live without one row per token.

Collapse matching reasoning snapshots in the web timeline and avoid stale session 404 redirects during refetch.

Tests: targeted Vitest suite and bun typecheck.

Co-authored-by: twshe <twshe@outlook.com>
2026-05-22 08:57:50 +08:00
SmallSpiderandGitHub 841b7cc035 Add Codex multi-agent timeline support (#588)
* checkpoint codex multiagent UI state

* fix codex multiagent event scoping

* fix: stabilize codex subagent timeline

* chore: remove codex subagent nesting prompt

* test(web): stabilize tool result rendering tests

* fix: collapse codex agent trace rows by default

* fix(web): keep chat scrolled to bottom

* fix(web): backfill agent-run-heavy message loads

* fix(codex): fail stuck subagent spawns

* fix(codex): preserve wrapped child event scope

* fix(codex): surface agent tool completions
2026-05-07 10:46:16 +08:00
Junmo KimandGitHub e17d7e5995 fix(web): align Agent tool dialog with TUI ctrl+o expand (#585) 2026-05-07 08:28:03 +08:00
Junmo KimandGitHub d9d7ed6699 feat(web): show message metadata (invoke time, duration, model) on click (#555)
* feat(web): show message metadata (invoke time, duration, model) on click

* fix(cli): preserve model field on assistant messages forwarded to hub

`RawMessageSchema` validates the `message` object in Claude Code session
JSONL lines before the cli forwards each message to the hub. Zod's default
parse mode strips fields that the schema does not declare, so the
`message.model` value (e.g. `claude-sonnet-4-6`) was silently removed
before the message reached the hub. The web normalizer reads
`data.message.model` to label assistant blocks, so without this field
every assistant message fell back to a generic "AI Model" label —
defeating the per-message model attribution this PR adds.

Add `model` to `RawMessageSchema` so it survives parse and reaches the
hub intact.

* fix(web): drop dead model shorthand in result envelope normalize

The `result/success` branch in `normalizeAgentRecord` referenced a `model`
identifier that was never declared in the function scope, breaking
`bun typecheck`. The reducer that consumes the resulting `turn-duration`
event does not look at `model` on the event itself, so the shorthand was
dead code. Remove it to restore typecheck.

* refactor(web): simplify turn-duration matcher with findLastIndex

Replace the imperative reverse-scan loops in the `turn-duration` reducer
branch with `findLastIndex`. The previous fallback also had an awkward
double-loop that mutated the matched block in place; using an index plus
a single immutable update keeps the block reference clean and makes the
match priority (id-prefix > tool-call id > last assistant-like) explicit.

Behaviour is unchanged — existing reducer tests cover both the messageId
match and the fallback paths.

* fix(web): preserve per-message model across mid-session model switches

The metadata footer fell back to `Session.model` from chat context when a
message did not carry its own `model`. That session value mutates when
the user switches models mid-session, so older messages were relabeled
with the latest model — including Codex/local assistant paths
(`AGENT_MESSAGE_PAYLOAD_TYPE`) that don't populate `msg.model`.

Drop the mutable-context fallback: pass `messageModel ?? null` to
`MessageMetadata` and let it omit the model line when no per-message
value is available. This is correct behaviour for messages whose
producer didn't record a model, and avoids ever attributing a message
to a model that didn't generate it.

Also remove the now-unused `useHappyChatContext` import in this file.

Add reducer invariants to lock in the data flow:
- `preserves per-message model across mid-session model switches`
- `leaves model undefined when message lacks per-message model`

* fix(web): keep tool-block reference identity when applying turn-duration

`ensureToolBlock` stores the same `ToolCallBlock` instance in both
`toolBlocksById` and `blocks`. The earlier refactor cloned the matched
block via `blocks[foundIndex] = { ...b, durationMs }`, which left the
map pointing at the stale original. A subsequent permission/result
mutation through `ensureToolBlock` would then update the stale map
object while the rendered `blocks` entry never sees the completion or
result, causing tool cards to miss state transitions.

Mutate the matched block in place instead — same in-place pattern the
reducer used before — and gate the assignment on the kinds that carry a
`durationMs` field so TypeScript narrows correctly.

Add an invariant test that fires a `turn-duration` event at a tool-call
block and asserts the rendered block and `toolBlocksById.get(...)`
remain the same object reference.

* fix(web): do not render service_tier as the model id

`MessageMetadata` previously fell back to `usage.service_tier` as the
"model" when no per-message `model` was available, so messages without
their own model id could surface labels like `Model: standard_only` —
service_tier is tier metadata, not a model.

Render the model line only when a real `model` is present; if a
non-`standard` `service_tier` is the only signal, surface it as a
separate `Tier: <tier>` label so it is not mistaken for the model.
The standard tier is the implicit default and is never rendered alone.

Extract the label-building logic into `buildMessageMetadataLabels` so
it can be unit-tested without a DOM. Add tests covering: model present,
model missing with non-standard tier, default standard tier, model with
non-standard tier appended, and the empty-input case.

* fix(web): metadata toggle ignores clicks on nested interactive controls

The bubble-level click handler that opens the metadata footer wraps
interactive descendants — tool-card buttons, retry buttons, dialog
triggers (Radix `role="button"`), and the Markdown code-copy button.
Clicking any of those flips the metadata footer as a side effect, even
when the descendant is the actual target of the user's intent.

Extract the closest-ancestor check into a small `metadataToggle` helper
and route both `AssistantMessage` and `UserMessage` click paths through
it. The toggle bails out when the click target sits inside any
`button`, `a`, `input`, `textarea`, `select`, or `[role="button"]`
ancestor; plain message-body text still toggles as before.

Add unit tests covering: button target, nested span inside a button,
`role="button"` Radix-style trigger, anchor/input/textarea/select form
controls, plain message-body text (no toggle), and a non-HTMLElement
target.

* fix(cli): preserve messageId on system/turn_duration record

`web/src/chat/normalizeAgent.ts` matches each `turn-duration` event to
the assistant block carrying the same `data.messageId`. Claude code
emits that field on the `system/turn_duration` record, but
`RawJSONLinesSchema`'s system branch did not declare `messageId`, so
Zod stripped it before the cli forwarded the record to the hub. The
matcher then fell back to "the last visible block", which is wrong for
interleaved/tool-heavy turns and silently attaches the duration to the
wrong assistant block.

Add `messageId: z.string().optional()` to the system schema so the id
survives parse and reaches the web reducer. Tests cover the preserved
case, the legacy case without `messageId`, and the previously-fixed
`message.model` case so Zod strip regressions on adjacent fields stay
locked in.

* fix(web): metadata toggle accepts SVG event targets

`isClickOnNestedControl` only walked up via `closest` when the click
target was an `HTMLElement`. The copy / retry / Markdown code-copy
buttons render SVG icons, so clicking the icon makes the event target
an `SVGElement` (not an `HTMLElement`) — the guard returned false and
the bubble-level click flipped the metadata footer anyway.

Widen the type check to `Element`, which is the common super-class of
both `HTMLElement` and `SVGElement` and also exposes `closest`. Plain
text targets and non-Element targets still behave as before.

Add a regression test that mounts an icon-only button (`<button><svg>
<path/></svg></button>`) and asserts both the `<svg>` and `<path>`
targets walk up to the enclosing button.

* refactor(cli): rely on Zod passthrough for jsonl envelopes

`RawMessageSchema` and the `system` branch of `RawJSONLinesSchema` were
declared with Zod's default `strip` mode, so any field the cli did not
explicitly enumerate was silently dropped before the hub forwarded the
record. The metadata pipeline lost `message.model` and
`system/turn_duration.messageId` exactly that way, and each gap took a
separate fix.

Switch both schemas to `.passthrough()` so undeclared fields survive
parse and reach the web reducer verbatim. Future SDK additions no
longer require another schema patch.

Add tests asserting that unknown keys on assistant messages and
unknown keys on system records (alongside the existing `messageId`
case) are preserved end-to-end through the schema.

* refactor(web): clean up dead metadata propagation surface

Several knobs were added to thread metadata through the chat tree but
ended up unused or redundant; consolidate them so the data flow has a
single canonical path.

- Drop the unreachable `data.type === 'result' && data.subtype ===
  'success'` branch in `normalizeAgentRecord`. Claude's `result`
  records are consumed by `claudeRemote` as session-completion signals
  and never forwarded to the hub; the cli `RawJSONLinesSchema`
  discriminator does not include `result`, so these records are
  rejected before they reach `normalizeAgentRecord` either way.
- Stop threading `invokedAt` through the inner `normalizeAssistantOutput`
  / `normalizeUserOutput` / `normalizeAgentRecord` calls. Every caller
  in `normalizeDecryptedMessage` already overwrites it via the outer
  spread, so the inner copies were dead writes. Set `invokedAt` only at
  the outer boundary.
- Remove the `model?: string | null` field from `HappyChatContextValue`
  and the `model` prop on `HappyThread` / `SessionChat`. Its only
  consumer (`AssistantMessage` mutable-fallback) was removed when the
  per-message model attribution fix landed; the prop has no readers
  now.
- Match the existing `as Partial<HappyChatMessageMetadata> | undefined`
  cast pattern in `AssistantMessage` and `UserMessage` instead of the
  non-`Partial` cast that pretended every field was present even when
  `custom` is undefined.
- Rename `AgentEvent.turn-duration.messageId` to `targetMessageId` so a
  reader does not confuse the duration's target with the surrounding
  envelope id; the wire field on Claude's `system/turn_duration` record
  stays `messageId` (vendor name) and is mapped at the normalize
  boundary.

No behaviour change. All existing tests pass.

* fix(web): turn-duration matcher and cli-output merge precedence

Two reducer-level metadata-correctness bugs surfaced during a hostile
self-review.

1. Turn-duration matcher silently dropped the duration when
   `targetMessageId` resolved to a non-duration-bearing block. The
   existing pipeline did `findLastIndex(b => b.id === targetId || ...)`
   first; if that hit an `agent-event` or `user-text` block (id-prefix
   collision), the kind guard at the assignment site failed and the
   duration was never attached. The fallback search ran only when the
   first pass returned -1, not when the kind check rejected the match.

   Fold the kind filter into every search predicate via a typed
   `isDurationTarget` helper so the priority `target-bearing match >
   tool-call id > last duration-bearing block` is exhaustive.

2. `mergeCliOutputBlocks` had asymmetric metadata precedence between
   the command-name block (`prev`) and the stdout follow-up (`block`):
   `invokedAt` and `model` preferred prev, but `durationMs` and `usage`
   preferred block. Only the command-name block carries first-class
   metadata; the stdout follow-up is a synthetic split. Use prev as the
   primary source uniformly and fall back to block only when prev is
   missing the field.

Tests cover the fallback path on the matcher and both precedence
scenarios on the merger.

* fix(web): preserve tool-call invokedAt across tool-result update

`ensureToolBlock` is called twice for the same tool: first with the
seed from the assistant's tool-use block, then with the seed from the
matching tool-result message. The second call's `seed.invokedAt` came
from the tool-result message and was unconditionally overwriting the
tool-call's original invokedAt. The rendered "Invoke" timestamp on a
tool card therefore showed when the result was processed, contradicting
the column header.

Guard the assignment so the timestamp survives the second call —
`existing.invokedAt ??= seed.invokedAt` semantics — while still letting
the first call set the value when the tool is created. `durationMs`,
`usage`, and `model` continue to overwrite because their values come
from the result message's usage block and are intentionally newer.

Add a regression test that fires a tool-use followed by a tool-result
with a later invokedAt and asserts the tool block keeps the original.

* fix(web): metadata footer UX, accessibility, and label hardening

Bundle the remaining UI surface fixes for the metadata footer.

- Make the bubble interactive only when there is metadata to disclose.
  Without the guard, every non-Claude session bubble (Codex / Cursor /
  Gemini, none of which populate `model`/`usage`/`durationMs` in the web
  layer) showed a pointer cursor and reacted to clicks even though
  `MessageMetadata` rendered nothing — false-positive interactivity.
- Add keyboard support: when the bubble is interactive it now exposes
  `role="button"`, `tabIndex=0`, `aria-expanded`, and an `Enter`/`Space`
  key handler so screen readers and keyboard-only users can disclose
  the footer the same way mouse users do.
- Fix nullish-vs-falsy bugs in the label builder: a 0 ms turn or a 0
  unix-epoch invokedAt no longer hides their lines. Use explicit
  `!= null` / `>= 0` checks.
- Rename the token total to "billable tokens" so the explicit exclusion
  of cache I/O is signalled in the label rather than implied by the
  number alone.
- Tag the queued/sending status spans with `role="status"` (and an
  accessible label) so they are announced by AT and so the metadata
  toggle's `closest('button, ..., [role="status"]')` filter does not
  accidentally fire when a user clicks a status icon.
- Add the native `<summary>` element and `[role="status"]` to the
  toggle's nested-control selector. Tool cards already render their
  expandable bodies as `<details><summary>` — clicking the summary now
  expands the disclosure without also flipping the metadata footer.

Tests cover: native `<summary>` target, `role="status"` target, the
billable label, durationMs=0 surfaced, invokedAt=0 surfaced,
invokedAt=null/undefined hidden.

* fix(web): expose cli-output metadata via dedicated toggle button

CliOutputBlock renders the entire card as a Dialog trigger <button>, so
the bubble-level click handler on the cli-output branch never opened the
metadata footer by mouse — every click landed inside that button and
isClickOnNestedControl bailed out. The wrapping div with role="button"
was also a nested-interactive a11y anti-pattern.

Drop the wrapper's role/onClick/tabIndex/keyDown on the cli-output
branch and render an explicit "Show metadata" / "Hide metadata" button
beneath the card. The dialog trigger keeps its full hit area; the
metadata footer is now reachable by both mouse and keyboard.

* fix(web): exclude toggle wrapper from nested-control guard

The bubble-level toggle wrappers in AssistantMessage / UserMessage
carry role="button" for keyboard accessibility. Without excluding
currentTarget, closest('[role="button"]') from any inner click matches
the wrapper itself and the toggle bails out — making the metadata
footer unreachable for mouse users (keyboard Enter/Space still worked,
which is why unit tests and bot review missed it).

Walk currentTarget out of the match: a nested control is one whose
closest matching ancestor is *not* the wrapper itself.

* fix(web): apply nested-control guard on keyboard activation too

The mouse path bailed via isClickOnNestedControl, but the keyboard path
on the metadata-toggle wrapper did not. Pressing Enter or Space on a
focused descendant control (e.g. Markdown code-copy button) bubbled the
keydown up and the wrapper toggled metadata alongside the descendant's
own activation.

Generalize the helper to isNestedInteractiveEvent over both
MouseEvent and KeyboardEvent and call it from onMetadataKeyDown in
AssistantMessage and UserMessage.
2026-05-03 12:51:22 +08:00
73fa846df3 fix(web): drop "No response requested." assistant messages (#402)
* fix(web): drop "No response requested." assistant messages

When Claude Code injects system messages (task notifications, system
reminders) as user turns, Claude responds with "No response requested."
In the HAPI web UI this appears as a reply to the user's message,
making it look like Claude is ignoring their input.

Filter these out in isSkippableAgentContent() (catches the fallback
path in normalize.ts) and in normalizeAssistantOutput() (catches the
primary path). Both checks verify the assistant message contains only
the text "No response requested." with no tool calls.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): filter text block instead of dropping entire message

Address bot review: dropping the whole normalized record breaks
sidechain UUID threading (parentUUID chain orphans).

Instead of returning null, suppress only the "No response requested."
text block during content extraction. The message record (uuid,
parentUUID, usage) is preserved so the tracer's sidechain grouping
continues to work.

Also remove the isSkippableAgentContent check since we no longer
need to drop the message at that layer.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): move "No response requested." filter to reducer layer

Address bot review: filtering in normalizeAssistantOutput() produced
empty content arrays, breaking traceMessages() which reads uuid and
parentUUID from content[0]. Sidechain child messages whose parentUUID
pointed to the filtered message became orphaned.

Fix: revert the normalizer to always emit the text block (preserving
the UUID chain for the tracer), and filter the sentinel text in
reducerTimeline.ts where text blocks become visible AgentTextBlocks.
At this point tracing is already complete.

Also adds reducer-level tests for the filter and updates the
normalize test to verify the text block is preserved.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): scope sentinel filter to single-block assistant messages only

Address bot review: the previous filter suppressed any text block
matching "No response requested.", which could hide legitimate replies.

Now the filter only triggers when the message has exactly one content
block (msg.content.length === 1) — i.e., the assistant response is
purely the sentinel text with no tool calls or reasoning blocks.
This prevents false positives while still catching the system-injection
auto-reply case.

Add test for the multi-block case (text + tool call) to verify the
sentinel text is preserved when other content exists.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): add parentUUID structural check to sentinel filter

Address bot review: raw text match alone could theoretically suppress
a legitimate reply.  Add c.parentUUID !== null as a structural guard:

- Sentinel auto-replies always follow a prior assistant turn, so their
  parentUUID is set (pointing to the previous message in the chain).
- A first message in a conversation has parentUUID: null and will
  never be filtered.

Combined conditions: msg.content.length === 1 (sole block, no tool
calls) AND c.parentUUID !== null (not the first reply) AND exact text
match.

Add tests for the parentUUID=null escape hatch.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): use injected-turn UUID tracking for sentinel filter

Address review: use structural markers instead of broad text matching.

1. Pre-scan collects UUIDs from sidechain content blocks (system-
   injected user turns). The sentinel filter now only triggers when
   parentUUID points to one of these known injected turns.

2. Move task-notification event extraction from normalizer to reducer.
   Previously, task-notifications with summary were normalized as
   role:'event', losing their uuid. Now they stay as sidechain (uuid
   preserved for pre-scan), and the reducer extracts the summary as
   an agent-event block.

3. Remove redundant 'uuid' in c guard (always present on sidechain type).

False positive analysis: a legitimate reply is only suppressed when ALL
of: (a) sole content block, (b) parentUUID matches a sidechain-injected
turn, (c) exact sentinel text. This combination cannot occur for real
user-facing content.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): add parentUUID to sidechain content type for tracer linkage

The sidechain content block was missing parentUUID, so traceMessages()
could not chain system-injected user turns (task notifications, system
reminders) inside a Task sidechain back to their parent. This caused
later sidechain messages pointing to the injected turn's UUID to become
orphaned and disappear from the Task card.

Add parentUUID to the sidechain type definition and propagate it from
normalizeUserOutput() in both the isSidechain and non-sidechain paths.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): handle array-content sidechain user messages to prevent prompt leak

Sidechain user messages can arrive with either string content or array
content ([{type:'text', text:'...'}]) depending on how Claude Code
serialises them. The previous fix only handled the string case, causing
intermittent prompt leaks when array format was used.

Now normalizeUserOutput extracts text from array-content sidechain
messages and emits them as sidechain blocks, so the tracer can match
them to their parent Task tool call.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(web): verify parentUUID propagation from assistant output data

Add integration tests confirming normalizeAssistantOutput correctly
maps data.parentUuid to text block parentUUID (used by the reducer's
sentinel detection). Tests cover both present and absent parentUuid.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-06 20:42:40 +08:00
Haoqing WangandGitHub 36022a0a1a fix(web): filter system-injected XML tags from rendering as raw text (#387)
* fix(web): filter system-injected XML tags from rendering as raw text

Claude Code injects internal messages (<task-notification>, <system-reminder>,
<command-name>, <local-command-caveat>) as user-role messages. The web UI was
rendering these as raw XML text visible to users.

- Parse <task-notification> and display as agent-event with summary text
- Silently drop <system-reminder>, <command-name>, <local-command-caveat>
- Add tests covering all injection prefixes and edge cases

* fix(web): scope system injection filtering to Claude sessions only

Address review feedback: the XML tag filtering was applied at the
generic timeline layer, which could incorrectly hide legitimate user
messages in Codex/Gemini sessions.

- Add isClaudeSession flag threaded from Session.metadata.claudeSessionId
- Only filter system-injected tags when isClaudeSession is true
- Add tests verifying non-Claude sessions pass through all messages

* fix(web): treat all string user output as sidechain to prevent prompt leaks

Restores the fix from 3cf96ab that was accidentally reverted in 2205e04.

In normalizeUserOutput(), string-content user messages arriving through
the agent output path are never real user input (real user text goes
through normalizeUserRecord). Previously, non-sidechain string messages
were emitted as role:'user', causing subagent prompts and system-injected
messages to render as user text in the web UI.

Now all string-content user messages in this path are:
- <task-notification> with summary → converted to role:'event'
- Everything else → marked as sidechain (matched to parent Task tool
  call by the tracer, or harmlessly skipped by the reducer)

This provides a root-level fix that prevents ANY string user message
from the agent output path from leaking as visible user text.

* ci: retrigger CI

* fix(web): remove superseded return-null filter from upstream PR #372

The upstream `return null` filter for <task-notification> and
<system-reminder> (from PR #372) is now superseded by the comprehensive
sidechain upgrade logic. Remove it to avoid short-circuiting the new
task-notification → event conversion.

* refactor(web): remove reducer-side system injection filtering

System-injected messages are now fully handled in normalizeUserOutput()
(normalize layer), so the redundant filtering in reduceTimeline() is no
longer needed. Removing it also eliminates the risk of accidentally
hiding legitimate user messages that happen to start with XML tags.

- Remove SYSTEM_INJECTION_PREFIXES, isSystemInjectedMessage,
  parseTaskNotificationSummary from reducerTimeline.ts
- Remove isClaudeSession plumbing from reducer.ts and SessionChat.tsx
- Simplify reducerTimeline.test.ts to only test pass-through behavior
2026-04-02 14:08:03 +08:00