Skip to content

[Dev UI] Decouple tool response state into reactive signals and modernize runner integrations #6211

Description

@MichaelDoyle

Description

Currently, tool action responses (manual inputs and runtime interrupts) are harvested using an imperative DOM-scraping pattern (parseMessageData(harvestToolResponses) querying viewChildren(JsonEditorComponent)).

This pattern was historically inherited from MessageComponent (which required DOM scraping due to contenteditable), but is an anti-pattern for structured JSON tool calls. We should refactor ToolActionCard to maintain its draft response state in reactive Signals (or a form-free CdkStepper), emit responses directly on submission, and decouple it from MessageList's DOM parsing.


Motivation & Current Limitations

  1. Forced DOM Persistence Hack ([class.hidden]):
    Because ToolActionCard scrapes this.editors() on submit, all steps in multi-tool interruptions must remain mounted simultaneously in the DOM using CSS display: none. If inactive steps used @if or lazy rendering, their editor instances would be destroyed and unsubmitted inputs lost.
  2. Index-Coupled and Fragile:
    parseMessageData iterates over this.messages() while manually incrementing parallel index counters (cardIndex++, componentIndex++). If the DOM tree and data array ever desynchronize, the indices misalign.
  3. No Reactive Validation:
    JSON validation only happens at submission time inside getToolCallResponses(), preventing real-time disabling of "Next" / "Resume" buttons when JSON is malformed.
  4. Runner State Mismatch:
    In AgentRunner, chatLog is a read-only computed() signal. Forcing it through a [(messages)] two-way binding and DOM-harvesting loop requires manual state synchronization hacks after scraping.

Proposed Architecture

flowchart TD
    TAC["<b>ToolActionCard (Reactive)</b><br/>• Holds draft responses in signals: <code>toolResponses = signal&lt;Record&lt;string, ...&gt;&gt;({})</code><br/>• Lazy-renders active step only (@if / CdkStepper)<br/>• Emits payload directly: <code>submitEvent = output&lt;ToolCallResponse[]&gt;()</code>"]

    ML["<b>MessageListComponent</b><br/>• Forwards <code>submitEvent</code> payload to parent runner<br/>• <code>parseMessageData()</code> strictly syncs contenteditable message text/media"]

    MR["<b>ModelRunner</b><br/>1. Receives payload<br/>2. Syncs prior edited messages via <code>parseMessageData()</code><br/>3. Dispatches request"]

    PR["<b>PromptRunner</b><br/>1. Receives payload<br/>2. Dispatches prompt run with resume payload"]

    AR["<b>AgentRunner</b><br/>1. Receives payload<br/>2. Bypasses message list DOM entirely<br/>3. Dispatches bidi / turn resume directly"]

    TAC -->|"(submitEvent)='onToolResume($event)'"| ML
    ML --> MR
    ML --> PR
    ML --> AR
Loading

Impact by Component

1. ToolActionCard

  • Maintain in-progress inputs in a signal: protected readonly toolResponses = signal<Record<string, ToolCallResponse>>({}).
  • Update draft state in memory on (contentChangeEvent) from JsonEditorComponent.
  • Lazy-render steps using @if (!hasMultipleToolCalls() || currentStep() === i) (or headless CdkStepper without forms), unmounting inactive Monaco/JSON editors without losing state.
  • Update submitEvent = output<ToolCallResponse[]>() to emit ready-to-use payloads directly.
  • Derive isResumeValid = computed(...) to reactively disable the Resume button if any step contains invalid JSON.

2. MessageListComponent

  • Remove the harvestToolResponses boolean flag and toolActionCards indexing from parseMessageData().
  • Keep parseMessageData() focused exclusively on extracting rich text and media from MessageComponent instances.
  • Forward (submitEvent)="submitEvent.emit($event)" with the payload.

3. ModelRunner

  • Why it still needs message parsing: In ModelRunner, multi-turn and Append Mode allow users to edit historical turns in-place (contenteditable). When resuming, the model API requires the entire conversation history along with the resume payload.
  • Changes: Catch the reactive tool response payload, call parseMessageData() to sync any edited historical messages, and package both into buildPrompt().

4. PromptRunner

  • Remove parseMessageData(true) call before running.
  • Pass the emitted ToolCallResponse[] payload directly into resume options.

5. AgentRunner

  • Remove the [(messages)]="chatLog()" two-way binding mismatch on the computed() signal in favor of one-way [messages]="chatLog()".
  • Handle (submitEvent)="sendMessage($event)" by dispatching the turn/bidi resume payload immediately without touching the message list DOM.

Acceptance Criteria

  • ToolActionCard holds draft state in memory/signals and emits ToolCallResponse[] on submitEvent.
  • Steps in multi-tool interruptions are rendered lazily with @if or CdkStepper rather than kept in DOM with display: none.
  • MessageListComponent.parseMessageData() no longer queries or mutates ToolActionCard.
  • In ModelRunner, user edits to historical messages are still captured when resuming a tool action.
  • In AgentRunner, message-list is bound one-way and tool resume bypasses parseMessageData().
  • Unit tests for ToolActionCard, MessageList, and all three runners updated to test the reactive data flow.

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions