From c1dafd0ecdfffb1e7b7131efc6dbfbba291eb52d Mon Sep 17 00:00:00 2001 From: DivX Date: Mon, 25 May 2026 18:18:11 +0800 Subject: [PATCH 01/11] docs: add ask_user form-in-chat design spec --- .../2026-05-25-ask-user-form-chat-design.md | 495 ++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-ask-user-form-chat-design.md diff --git a/docs/superpowers/specs/2026-05-25-ask-user-form-chat-design.md b/docs/superpowers/specs/2026-05-25-ask-user-form-chat-design.md new file mode 100644 index 000000000..20c85cb50 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-ask-user-form-chat-design.md @@ -0,0 +1,495 @@ +# Ask User Form-In-Chat Design + +## Goal + +Add a new `ask_user` presentation mode that renders a full form card directly inside the chat message stream for larger, structured questionnaires. The form must remain in the thread after submission as a read-only record, survive page refresh, and coexist with the current inline `ask_user` flow for simpler prompts. + +## Scope + +This design covers: + +- Extending `ask_user` to support an explicit `display_mode` +- Persisting form-mode `ask_user` interactions as thread messages +- Keeping the current run input submission pipeline unchanged +- Rendering pending and submitted form cards inside the Web chat stream +- Preserving existing inline `ask_user` behavior for non-form usage + +This design does not cover: + +- Auto-switching modes based on field count +- New agent-side planning heuristics for when to use form mode +- Editable history after a form has been submitted +- A new generalized workflow engine for multi-step surveys + +## Product Decisions + +- Mode selection is explicit. Agents choose `display_mode=form` or keep the existing inline behavior. +- Form cards are shown inside the chat message stream. +- After submission, the same form card remains in place and becomes read-only. +- Refreshing or reopening the thread must still show the submitted form card. +- Submitted form cards show the full field-by-field response, not a compact summary. +- Inline `ask_user` remains the default and continues to use the existing bottom-area interaction for simpler prompts. + +## Current State + +Today `ask_user` works as a live run interaction only: + +- Worker emits `run.input_requested` +- Web stores a temporary `pendingUserInput` +- The UI renders `UserInputCard` above the composer +- Submission goes to `POST /v1/runs/{run_id}/input` +- API stores `run.input_provided` +- Worker resumes and forwards the answer into the next LLM turn + +This flow is useful for live interaction, but it is not sufficient for persistent chat history because: + +- The card is not represented as a thread message +- Refresh depends on transient run state rather than message history +- There is no persistent read-only artifact of the structured response in the thread + +## High-Level Approach + +Use a mixed architecture: + +- `run_events` remain the control plane for pause, resume, timeout, and answer delivery to the worker +- `messages` become the persistence plane for form-mode chat rendering and replay + +For `display_mode=form`, the system will: + +1. Keep the existing `run.input_requested -> /runs/{id}/input -> run.input_provided` pipeline +2. Create a thread message that represents the form card +3. Update that thread message in place when the form is submitted, dismissed, or expires + +For non-form `ask_user`, the system will keep the existing transient inline card behavior unchanged. + +## Architecture + +### Responsibility Split + +#### Run layer + +The run layer remains responsible for: + +- Pausing the run while input is required +- Emitting `run.input_requested` +- Accepting serialized user input through `/v1/runs/{id}/input` +- Resuming the worker after input is received +- Preserving prompt scan and timeout semantics + +#### Message layer + +The message layer becomes responsible for: + +- Showing a form card in the chat stream +- Preserving that card across refresh and thread reload +- Recording the final read-only response shape shown to the user + +### Why this split + +This keeps the existing worker protocol stable while giving the chat UI a durable, replayable source of truth. It avoids rebuilding history from run events and avoids overloading thread messages with execution control behavior. + +## Ask User Contract Changes + +### New argument + +Extend `ask_user` to accept an optional `display_mode`. + +Supported values: + +- `inline` +- `form` + +Behavior: + +- Missing `display_mode` is treated as `inline` +- `inline` preserves current behavior +- `form` enables persistent form-in-chat rendering + +### Validation + +`ValidateAndNormalize` should: + +- Accept `display_mode` only when it is `inline` or `form` +- Include the normalized value in the returned schema payload or a companion metadata payload +- Continue validating fields exactly as today + +The normalized result emitted to the client for form mode must include enough data to: + +- Render the form +- Associate it with a run and request id +- Persist it as a structured message + +## Persistent Message Model + +### New structured message content kind + +Add a new thread message content kind for form-mode `ask_user` cards. + +Recommended shape: + +```json +{ + "kind": "ask_user_form", + "display_mode": "form", + "request_id": "call_123", + "run_id": "run_uuid", + "tool_call_id": "call_123", + "message": "Please fill out the deployment checklist", + "schema": { + "properties": {}, + "required": [], + "_fieldOrder": [] + }, + "status": "pending", + "answers": null, + "submitted_at": null +} +``` + +### Status lifecycle + +Allowed states: + +- `pending` +- `submitted` +- `dismissed` +- `expired` + +Rules: + +- `pending` is editable only when it belongs to the active waiting run +- `submitted` is always read-only +- `dismissed` is read-only and indicates the user intentionally skipped the form +- `expired` is read-only and indicates the run stopped waiting before submission + +### Storage choice + +Use existing thread message persistence and store this as structured `content_json` rather than inventing a second durable store. This makes form cards compatible with: + +- Thread reload +- Existing message list APIs +- Chat rendering +- Potential future share/export behavior + +## Backend Flow + +### 1. Worker emits form request + +When `ask_user` runs with `display_mode=form`: + +1. Worker validates and normalizes input +2. Worker emits `run.input_requested` as it does today +3. The system creates a new thread message with `kind=ask_user_form` and `status=pending` + +The message must be linked to: + +- `thread_id` +- `run_id` +- `request_id` + +There must be at most one persisted form card per `(run_id, request_id)` pair. + +### 2. Frontend shows pending form card + +The Web app receives either: + +- The new thread message via thread message loading, or +- A thread-local message update event if added later + +The card is rendered in the normal chat stream, not above the composer. + +### 3. User submits the form + +Submission remains unchanged at the run protocol level: + +- Frontend serializes `answers` as JSON +- Frontend calls `POST /v1/runs/{run_id}/input` + +After the API writes `run.input_provided`, it also updates the matching `ask_user_form` message: + +- `status: submitted` +- `answers: { ... }` +- `submitted_at: now` + +The worker then resumes as it does today. + +### 4. User dismisses the form + +If the user dismisses the form: + +- Frontend submits the agreed dismissal payload through `/v1/runs/{run_id}/input` +- API updates the persisted message to `dismissed` + +The card remains in chat as a read-only dismissed artifact. + +### 5. Run times out or ends while still pending + +If the run stops waiting without a submission: + +- Pending form messages for that `(run_id, request_id)` become `expired` + +This transition should happen from a reliable backend path so the frontend does not need to infer expiry solely from missing active run state. + +## API Behavior + +### Keep the current submission endpoint + +Do not introduce a new form submission endpoint. Reuse: + +- `POST /v1/runs/{run_id}/input` + +Reason: + +- The worker already understands this path +- Prompt scan and timeout semantics already exist +- This avoids diverging run input semantics between inline and form modes + +### Needed API-side enhancement + +`ProvideInput` handling must gain the ability to: + +- Detect whether the active waiting request corresponds to a persisted form message +- Update that message atomically or near-atomically after writing `run.input_provided` + +The API update path must be idempotent enough to safely tolerate retries. + +## Frontend Rendering + +### Mode split + +#### Inline mode + +Keep current behavior: + +- `run.input_requested` sets `pendingUserInput` +- `UserInputCard` appears above the composer +- Submit or dismiss clears the temporary pending state + +#### Form mode + +New behavior: + +- Render from thread messages, not temporary composer-adjacent state +- The card lives in the chat stream +- After submission, the card stays in place and becomes read-only + +### New message component + +Add a dedicated message renderer, for example: + +- `AskUserFormMessageCard` + +Responsibilities: + +- Render editable form fields for `pending` +- Render full read-only field/value pairs for `submitted` +- Render state treatment for `dismissed` and `expired` + +### Editable rules + +A pending form card is editable only when all of the following are true: + +- The card status is `pending` +- Its `run_id` matches the active waiting run +- The current run is actually waiting for input + +Otherwise the card is rendered read-only to avoid submitting data to the wrong run. + +### Submission UX + +On submit: + +- Call the existing `provideInput` +- Do not remove the card +- Wait for the persisted message state to become `submitted` +- Re-render the same message as read-only + +This avoids local-only optimistic state that can drift from server truth. + +### Full submitted display + +For `submitted`, show: + +- The original form prompt +- Every field in stable order +- Each selected or entered value + +No compact summary is needed for the first version. + +## Refresh and Replay + +### Desired behavior + +After refresh or reopening the thread: + +- Submitted form cards appear from thread history +- Their full field values remain visible + +### Pending historical forms + +If a `pending` card exists in message history but there is no active compatible waiting run: + +- Render the card as non-editable +- Show clear state language that it is no longer awaiting input + +The backend should prefer converting truly abandoned pending cards to `expired`, but the frontend must still guard against stale editability. + +## Event Handling Strategy + +### `run.input_requested` + +This event remains necessary, but its frontend meaning changes for form mode. + +For `inline`: + +- Continue creating temporary `pendingUserInput` + +For `form`: + +- Do not open the temporary bottom card UI +- Use the event only to know the run is waiting +- Let thread message rendering drive the visible card + +### Why not reconstruct from SSE alone + +SSE is not a sufficient durable source for this feature because: + +- Reconnect behavior is not the same as loading thread history +- Refresh should not require replaying the live run stream +- Thread messages are the correct replay surface for chat artifacts + +## Data Consistency Requirements + +### Correlation + +Every form-mode request must be correlatable by: + +- `run_id` +- `request_id` + +Optional but useful: + +- `tool_call_id` +- `message_id` + +### Invariants + +- A form-mode `ask_user` request creates exactly one persisted form message +- A submitted or dismissed form message is never editable again +- The final displayed answers must match the payload sent to `/runs/{id}/input` +- Inline-mode requests must not create persistent form messages + +## Backward Compatibility + +This design is backward-compatible because: + +- Existing `ask_user` calls without `display_mode` remain inline +- Existing run input submission API remains unchanged +- Existing worker wait/resume logic remains unchanged +- Existing `UserInputCard` stays in place for simple flows + +No migration of old `run.input_requested` events is required. This is a forward-only feature addition. + +## Error Handling + +### Submission failure + +If `/runs/{id}/input` fails: + +- Keep the form card visible and editable +- Show inline error feedback +- Do not locally mark the card as submitted + +### Duplicate submission + +If the user retries after a network error: + +- API-side message update must be safe to repeat +- The final persisted message should still end in a single `submitted` state + +### Stale active run + +If the run is no longer active: + +- Submission should fail with the existing run-not-active behavior +- Frontend should stop treating the card as editable after refresh or fresh run state is known + +## Testing Strategy + +### Go tests + +Add coverage for: + +- `ask_user` with `display_mode=form` validating successfully +- Form-mode request creating one persistent form message +- `/runs/{id}/input` transitioning the message from `pending` to `submitted` +- Dismiss transition to `dismissed` +- Timeout or terminal run transition to `expired` +- Inline mode preserving existing behavior and not creating persistent form messages + +### Web tests + +Add coverage for: + +- Rendering a pending form card inside the chat stream +- Submitting and re-rendering the same card as read-only +- Refresh/reload showing submitted cards from message history +- Inline mode continuing to render via the existing temporary card +- Stale pending cards being non-editable when no active waiting run exists + +## File Impact + +Expected main touchpoints: + +### Backend + +- `src/services/worker/internal/tools/builtin/askuser/` +- `src/services/worker/internal/agent/loop.go` +- `src/services/api/internal/http/conversationapi/v1_runs.go` +- `src/services/api/internal/data/messages_repo.go` +- Message content normalization and response serialization paths + +### Frontend + +- `src/apps/web/src/components/ChatView.tsx` +- `src/apps/web/src/components/UserInputCard.tsx` +- New `src/apps/web/src/components/AskUserFormMessageCard.tsx` +- `src/apps/web/src/hooks/useThreadSseEffect.ts` +- `src/apps/web/src/hooks/useChatActions.ts` +- `src/apps/web/src/agent-ui/event-data.ts` +- Message content rendering and type definitions + +## Open Decisions Resolved + +- Mode selection: explicit by agent, no auto-threshold logic +- Form persistence: yes, via thread messages +- Post-submit behavior: remain in place and become read-only +- Submitted display: full form replay, not compact summary +- Delivery path: reuse existing run input endpoint + +## Recommended Implementation Order + +1. Extend `ask_user` contract to accept `display_mode` +2. Add persistent message schema for `ask_user_form` +3. Create backend correlation and state transition logic +4. Add frontend message renderer for form cards +5. Rewire SSE handling so form mode does not use temporary composer-adjacent UI +6. Add tests for both the new form path and the unchanged inline path + +## Risks + +### Message/run coordination risk + +There is new coordination between execution state and message state. If correlation is weak, the wrong card could be updated. This is why `(run_id, request_id)` must be treated as the primary identity. + +### Refresh-state mismatch risk + +If the backend does not reliably close out abandoned pending forms, refresh may show stale pending cards. Frontend guards reduce the impact, but backend expiry transitions are still important. + +### Renderer drift risk + +If form cards and inline cards diverge too much, future maintenance will become awkward. Shared field rendering helpers should be reused where practical even if the message container is different. + +## Recommendation + +Proceed with the mixed architecture. It preserves the stable run control path while introducing a durable, replayable chat artifact for form-mode `ask_user`. This is the cleanest way to satisfy in-chat form UX, read-only post-submit history, and refresh-safe thread replay without regressing the current inline experience. From 2b4a46a628bf6cfaf5b625ab836feb94c7bca86a Mon Sep 17 00:00:00 2001 From: DivX Date: Mon, 25 May 2026 18:32:20 +0800 Subject: [PATCH 02/11] worker: add ask_user display mode --- src/services/worker/internal/agent/loop.go | 6 +++ .../worker/internal/agent/loop_test.go | 43 +++++++++++++++++ .../worker/internal/executor/lua_test.go | 48 +++++++++++++++++++ .../tools/builtin/askuser/executor.go | 15 +++++- .../internal/tools/builtin/askuser/spec.go | 5 ++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/services/worker/internal/agent/loop.go b/src/services/worker/internal/agent/loop.go index 5a8d215a5..73237fc23 100644 --- a/src/services/worker/internal/agent/loop.go +++ b/src/services/worker/internal/agent/loop.go @@ -642,10 +642,16 @@ func (l *Loop) Run( continue } + displayMode := "inline" + if raw, _ := schema["display_mode"].(string); strings.TrimSpace(raw) != "" { + displayMode = strings.TrimSpace(raw) + } + if err := yield(emitter.Emit("run.input_requested", map[string]any{ "request_id": requestID, "message": message, "requestedSchema": schema, + "display_mode": displayMode, }, nil, nil)); err != nil { return err } diff --git a/src/services/worker/internal/agent/loop_test.go b/src/services/worker/internal/agent/loop_test.go index 41ed305e2..aa1fc4b18 100644 --- a/src/services/worker/internal/agent/loop_test.go +++ b/src/services/worker/internal/agent/loop_test.go @@ -19,6 +19,7 @@ import ( "arkloop/services/worker/internal/security" "arkloop/services/worker/internal/tools" "arkloop/services/worker/internal/tools/builtin" + "arkloop/services/worker/internal/tools/builtin/askuser" channeltelegram "arkloop/services/worker/internal/tools/builtin/channel_telegram" heartbeattool "arkloop/services/worker/internal/tools/builtin/heartbeat_decision" "github.com/google/uuid" @@ -4437,6 +4438,9 @@ func TestAskUserLoopIntercept(t *testing.T) { if ev.DataJSON["request_id"] != "call_askuser" { t.Fatalf("unexpected request_id: %v", ev.DataJSON["request_id"]) } + if gotMode, _ := ev.DataJSON["display_mode"].(string); gotMode != "inline" { + t.Fatalf("display_mode = %q, want inline", gotMode) + } case EventTypeRunPaused: hasPaused = true case EventTypeRunResumed: @@ -4474,6 +4478,45 @@ func TestAskUserLoopIntercept(t *testing.T) { } } +func TestValidateAndNormalizeAcceptsDisplayModeForm(t *testing.T) { + args := map[string]any{ + "message": "Choose deployment options", + "display_mode": "form", + "fields": []any{ + map[string]any{ + "key": "region", + "type": "string", + "enum": []any{"us-east-1", "ap-southeast-1"}, + "required": true, + }, + }, + } + + message, schema, err := askuser.ValidateAndNormalize(args) + if err != nil { + t.Fatalf("ValidateAndNormalize returned error: %v", err) + } + if message != "Choose deployment options" { + t.Fatalf("unexpected message: %q", message) + } + if gotMode, _ := schema["display_mode"].(string); gotMode != "form" { + t.Fatalf("display_mode = %q, want form", gotMode) + } +} + +func TestValidateAndNormalizeRejectsUnknownDisplayMode(t *testing.T) { + _, _, err := askuser.ValidateAndNormalize(map[string]any{ + "message": "bad mode", + "display_mode": "wizard", + "fields": []any{ + map[string]any{"key": "ok", "type": "string"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "display_mode") { + t.Fatalf("expected display_mode error, got %v", err) + } +} + func TestAskUserNoWaitForInput(t *testing.T) { gateway := &askUserGateway{} loop := NewLoop(gateway, nil) diff --git a/src/services/worker/internal/executor/lua_test.go b/src/services/worker/internal/executor/lua_test.go index 15bb5c6e1..60cdc97b7 100644 --- a/src/services/worker/internal/executor/lua_test.go +++ b/src/services/worker/internal/executor/lua_test.go @@ -1253,6 +1253,54 @@ if err then error(err) end } } +func TestLuaExecutor_AgentLoop_AskUserFormIncludesDisplayMode(t *testing.T) { + gw := &captureGateway{ + events: [][]llm.StreamEvent{ + { + llm.ToolCall{ + ToolCallID: "call_ask_user_form", + ToolName: "ask_user", + ArgumentsJSON: map[string]any{ + "message": "Fill release form", + "display_mode": "form", + "fields": []any{ + map[string]any{"key": "version", "type": "string", "required": true}, + }, + }, + }, + llm.StreamRunCompleted{}, + }, + { + llm.StreamMessageDelta{ContentDelta: "handled", Role: "assistant"}, + llm.StreamRunCompleted{}, + }, + }, + } + + rc := buildLuaRC(gw) + rc.WaitForInput = func(_ context.Context) (string, bool) { + return `{"version":"1.2.3"}`, true + } + + evs := runLuaScript(t, ` +local ok, err = agent.loop("system", "query") +if err then error(err) end +`, rc) + + found := false + for _, ev := range evs { + if ev.Type != "run.input_requested" { + continue + } + if gotMode, _ := ev.DataJSON["display_mode"].(string); gotMode == "form" { + found = true + } + } + if !found { + t.Fatal("expected run.input_requested to include display_mode=form") + } +} + func TestLuaExecutor_AgentStreamRoute_UsesResolvedRoute(t *testing.T) { mainGW := &luaSeqGateway{ events: []llm.StreamEvent{ diff --git a/src/services/worker/internal/tools/builtin/askuser/executor.go b/src/services/worker/internal/tools/builtin/askuser/executor.go index 93c937242..3b388179c 100644 --- a/src/services/worker/internal/tools/builtin/askuser/executor.go +++ b/src/services/worker/internal/tools/builtin/askuser/executor.go @@ -3,6 +3,7 @@ package askuser import ( "context" "fmt" + "strings" "time" "arkloop/services/worker/internal/tools" @@ -53,6 +54,15 @@ func ValidateAndNormalize(args map[string]any) (string, map[string]any, error) { return "", nil, fmt.Errorf("missing required field: message") } + displayMode, _ := args["display_mode"].(string) + displayMode = strings.TrimSpace(displayMode) + if displayMode == "" { + displayMode = "inline" + } + if displayMode != "inline" && displayMode != "form" { + return "", nil, fmt.Errorf("display_mode must be one of inline, form") + } + fieldsRaw, ok := args["fields"] if !ok { return "", nil, fmt.Errorf("missing required field: fields") @@ -95,7 +105,10 @@ func ValidateAndNormalize(args map[string]any) (string, map[string]any, error) { } } - schema := map[string]any{"properties": properties} + schema := map[string]any{ + "properties": properties, + "display_mode": displayMode, + } if len(orderedKeys) > 0 { schema["_fieldOrder"] = orderedKeys } diff --git a/src/services/worker/internal/tools/builtin/askuser/spec.go b/src/services/worker/internal/tools/builtin/askuser/spec.go index ba491c3d4..31d9f3bd2 100644 --- a/src/services/worker/internal/tools/builtin/askuser/spec.go +++ b/src/services/worker/internal/tools/builtin/askuser/spec.go @@ -83,6 +83,11 @@ var LlmSpec = llm.ToolSpec{ "type": "string", "description": "A clear message describing what you need from the user.", }, + "display_mode": map[string]any{ + "type": "string", + "enum": []string{"inline", "form"}, + "description": "How the client should present the question. inline keeps the temporary composer card, form persists a card in the chat stream.", + }, "fields": map[string]any{ "type": "array", "description": "Form field definitions. Each item is a field rendered in the form.", From 2d0f9ab75c47726fe6bbaa8a410d5dd52b8481ac Mon Sep 17 00:00:00 2001 From: DivX Date: Mon, 25 May 2026 21:48:53 +0800 Subject: [PATCH 03/11] feat: persist and render ask_user form messages in chat history - Persist pending form messages as thread messages on run.input_requested - Finalize form messages (submitted/dismissed) on input submission - Expire pending forms on terminal run end (completed/cancelled/failed) - Add AskUserFormMessageCard component for editable and read-only views - Route form-mode input-request events through message stream instead of overlay - Widen web message types to support AskUserFormContent tagged union Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-25-ask-user-form-chat.md | 1061 +++++++++++++++++ src/apps/web/src/agent-ui/arkloop-adapter.ts | 50 +- src/apps/web/src/agent-ui/contract.ts | 22 +- src/apps/web/src/agent-ui/index.ts | 1 + src/apps/web/src/api.ts | 22 +- .../src/components/AskUserFormMessageCard.tsx | 124 ++ src/apps/web/src/components/ChatView.tsx | 6 + src/apps/web/src/components/MessageList.tsx | 35 + src/apps/web/src/components/SharePage.tsx | 2 +- .../buildConversationGraph.ts | 5 +- src/apps/web/src/contexts/thread-list.tsx | 2 +- src/apps/web/src/hooks/useChatActions.ts | 38 +- src/apps/web/src/hooks/useThreadSseEffect.ts | 9 + src/apps/web/src/messageContent.ts | 21 +- .../api/internal/data/messages_repo.go | 87 +- .../internal/http/conversationapi/register.go | 1 + .../internal/http/conversationapi/v1_runs.go | 29 +- .../internal/app/composition_desktop.go | 43 + .../worker/internal/data/messages_repo.go | 131 ++ .../internal/data/messages_repo_desktop.go | 131 ++ .../internal/pipeline/handler_agent_loop.go | 46 +- ...andler_agent_loop_sub_agent_events_test.go | 16 +- 22 files changed, 1853 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-25-ask-user-form-chat.md create mode 100644 src/apps/web/src/components/AskUserFormMessageCard.tsx diff --git a/docs/superpowers/plans/2026-05-25-ask-user-form-chat.md b/docs/superpowers/plans/2026-05-25-ask-user-form-chat.md new file mode 100644 index 000000000..50ac1f8f9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-ask-user-form-chat.md @@ -0,0 +1,1061 @@ +# Ask User Form-In-Chat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an explicit `ask_user display_mode=form` flow that renders a full form card inside the Web chat stream, persists it as a thread message, and converts it to a read-only full-response card after submission while keeping the existing inline flow unchanged. + +**Architecture:** Keep run input control on the existing `run.input_requested -> /v1/runs/{id}/input -> run.input_provided` path, and add a parallel persistence path that stores form-mode requests as structured thread messages. The worker emits enough metadata for form-mode requests, the worker/app event writer creates and finalizes form messages, the API/Web types widen to support a new content kind, and the Web renderer switches form-mode requests from temporary composer UI to message-stream UI. + +**Tech Stack:** Go 1.26, React 19, TypeScript 5.9, Vitest, pgx, existing Arkloop message content and SSE run-event plumbing. + +--- + +## File Structure + +### Backend protocol and worker plumbing + +- Modify: `src/services/worker/internal/tools/builtin/askuser/spec.go` +- Modify: `src/services/worker/internal/tools/builtin/askuser/executor.go` +- Modify: `src/services/worker/internal/agent/loop.go` +- Modify: `src/services/worker/internal/executor/lua_test.go` +- Modify: `src/services/worker/internal/agent/loop_test.go` + +### Worker/app persistence and terminal state transitions + +- Modify: `src/services/worker/internal/pipeline/handler_agent_loop.go` +- Modify: `src/services/worker/internal/app/composition_desktop.go` +- Modify: `src/services/worker/internal/app/composition_desktop_test.go` +- Modify: `src/services/worker/internal/app/composition_desktop_readonly_test.go` + +### API-side message helpers and input submission update + +- Modify: `src/services/api/internal/data/messages_repo.go` +- Modify: `src/services/api/internal/http/conversationapi/v1_runs.go` +- Modify: `src/services/api/internal/data/runs_repo_desktop_test.go` +- Modify: `src/services/api/internal/http/conversationapi/v1_messages.go` +- Modify: `src/services/api/internal/http/conversationapi/message_content.go` + +### Web types, adapters, and rendering + +- Modify: `src/apps/web/src/api.ts` +- Modify: `src/apps/web/src/agent-ui/contract.ts` +- Modify: `src/apps/web/src/agent-ui/arkloop-adapter.ts` +- Modify: `src/apps/web/src/messageContent.ts` +- Modify: `src/apps/web/src/hooks/useThreadSseEffect.ts` +- Modify: `src/apps/web/src/hooks/useChatActions.ts` +- Modify: `src/apps/web/src/components/ChatView.tsx` +- Modify: `src/apps/web/src/components/MessageList.tsx` +- Create: `src/apps/web/src/components/AskUserFormMessageCard.tsx` + +### Web tests + +- Modify: `src/apps/web/src/__tests__/userInputCard.test.tsx` +- Modify: `src/apps/web/src/__tests__/chatPageLoading.test.tsx` + +--- + +### Task 1: Extend `ask_user` Contract for Explicit Form Mode + +**Files:** +- Modify: `src/services/worker/internal/tools/builtin/askuser/spec.go` +- Modify: `src/services/worker/internal/tools/builtin/askuser/executor.go` +- Modify: `src/services/worker/internal/agent/loop.go` +- Test: `src/services/worker/internal/executor/lua_test.go` +- Test: `src/services/worker/internal/agent/loop_test.go` + +- [ ] **Step 1: Write failing worker tests for `display_mode` validation and event emission** + +```go +func TestValidateAndNormalizeAcceptsDisplayModeForm(t *testing.T) { + args := map[string]any{ + "message": "Choose deployment options", + "display_mode": "form", + "fields": []any{ + map[string]any{ + "key": "region", + "type": "string", + "enum": []any{"us-east-1", "ap-southeast-1"}, + "required": true, + }, + }, + } + + message, schema, err := ValidateAndNormalize(args) + if err != nil { + t.Fatalf("ValidateAndNormalize returned error: %v", err) + } + if message != "Choose deployment options" { + t.Fatalf("unexpected message: %q", message) + } + if got, _ := schema["display_mode"].(string); got != "form" { + t.Fatalf("display_mode = %q, want form", got) + } +} + +func TestValidateAndNormalizeRejectsUnknownDisplayMode(t *testing.T) { + _, _, err := ValidateAndNormalize(map[string]any{ + "message": "bad mode", + "display_mode": "wizard", + "fields": []any{ + map[string]any{"key": "ok", "type": "string"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "display_mode") { + t.Fatalf("expected display_mode error, got %v", err) + } +} +``` + +```go +func TestLuaExecutor_AgentLoop_AskUserFormIncludesDisplayMode(t *testing.T) { + gw := &captureGateway{ + events: [][]llm.StreamEvent{{ + llm.ToolCall{ + ToolCallID: "call_ask_user_form", + ToolName: "ask_user", + ArgumentsJSON: map[string]any{ + "message": "Fill release form", + "display_mode": "form", + "fields": []any{ + map[string]any{"key": "version", "type": "string", "required": true}, + }, + }, + }, + llm.StreamRunCompleted{}, + }}, + } + + rc := buildLuaRC(gw) + rc.WaitForInput = func(_ context.Context) (string, bool) { return `{"version":"1.2.3"}`, true } + + evs := runLuaScript(t, `local ok, err = agent.loop("system", "query"); if err then error(err) end`, rc) + + found := false + for _, ev := range evs { + if ev.Type == "run.input_requested" { + if got, _ := ev.DataJSON["display_mode"].(string); got == "form" { + found = true + } + } + } + if !found { + t.Fatal("expected run.input_requested to include display_mode=form") + } +} +``` + +- [ ] **Step 2: Run worker tests to verify they fail before implementation** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/tools/builtin/askuser ./internal/agent ./internal/executor -run 'TestValidateAndNormalize|TestLuaExecutor_AgentLoop_AskUserFormIncludesDisplayMode|TestAskUserLoopIntercept' +``` + +Expected: + +```text +FAIL ... TestValidateAndNormalizeAcceptsDisplayModeForm +FAIL ... TestLuaExecutor_AgentLoop_AskUserFormIncludesDisplayMode +``` + +- [ ] **Step 3: Extend the tool schema and normalization result** + +```go +// src/services/worker/internal/tools/builtin/askuser/spec.go +"display_mode": map[string]any{ + "type": "string", + "enum": []string{"inline", "form"}, + "description": "How the client should present the question. inline keeps the temporary composer card, form persists a card in the chat stream.", +}, +``` + +```go +// src/services/worker/internal/tools/builtin/askuser/executor.go +func ValidateAndNormalize(args map[string]any) (string, map[string]any, error) { + message, _ := args["message"].(string) + if message == "" { + return "", nil, fmt.Errorf("missing required field: message") + } + + displayMode, _ := args["display_mode"].(string) + displayMode = strings.TrimSpace(displayMode) + if displayMode == "" { + displayMode = "inline" + } + if displayMode != "inline" && displayMode != "form" { + return "", nil, fmt.Errorf("display_mode must be one of inline, form") + } + + // existing fields validation stays in place... + + schema := map[string]any{ + "properties": properties, + "display_mode": displayMode, + } + if len(orderedKeys) > 0 { + schema["_fieldOrder"] = orderedKeys + } + if len(requiredKeys) > 0 { + schema["required"] = requiredKeys + } + return message, schema, nil +} +``` + +- [ ] **Step 4: Include `display_mode` in `run.input_requested` events** + +```go +// src/services/worker/internal/agent/loop.go +displayMode := "inline" +if raw, _ := schema["display_mode"].(string); strings.TrimSpace(raw) != "" { + displayMode = strings.TrimSpace(raw) +} + +if err := yield(emitter.Emit("run.input_requested", map[string]any{ + "request_id": requestID, + "message": message, + "requestedSchema": schema, + "display_mode": displayMode, +}, nil, nil)); err != nil { + return err +} +``` + +- [ ] **Step 5: Re-run worker tests to verify the contract change passes** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/tools/builtin/askuser ./internal/agent ./internal/executor -run 'TestValidateAndNormalize|TestLuaExecutor_AgentLoop_AskUserFormIncludesDisplayMode|TestAskUserLoopIntercept' +``` + +Expected: + +```text +ok arkloop/services/worker/internal/tools/builtin/askuser ... +ok arkloop/services/worker/internal/agent ... +ok arkloop/services/worker/internal/executor ... +``` + +- [ ] **Step 6: Commit the protocol change** + +```bash +git add src/services/worker/internal/tools/builtin/askuser/spec.go \ + src/services/worker/internal/tools/builtin/askuser/executor.go \ + src/services/worker/internal/agent/loop.go \ + src/services/worker/internal/executor/lua_test.go \ + src/services/worker/internal/agent/loop_test.go +git commit -m "feat: add ask_user display mode" +``` + +### Task 2: Persist Pending Form Requests as Thread Messages + +**Files:** +- Modify: `src/services/api/internal/data/messages_repo.go` +- Modify: `src/services/worker/internal/pipeline/handler_agent_loop.go` +- Modify: `src/services/worker/internal/app/composition_desktop.go` +- Test: `src/services/worker/internal/app/composition_desktop_test.go` + +- [ ] **Step 1: Write a failing integration test for persistent pending form messages** + +```go +func TestAskUserFormCreatesPendingThreadMessage(t *testing.T) { + // Arrange a run that emits ask_user with display_mode=form and then pauses. + // Assert that one thread message exists with a structured ask_user_form payload. + msgs, err := messageRepo.ListByThread(ctx, run.AccountID, run.ThreadID, 50, 0) + if err != nil { + t.Fatalf("ListByThread failed: %v", err) + } + + var found bool + for _, msg := range msgs { + var payload map[string]any + if err := json.Unmarshal(msg.ContentJSON, &payload); err != nil { + continue + } + if payload["kind"] == "ask_user_form" && payload["status"] == "pending" { + found = true + if payload["run_id"] != run.ID.String() { + t.Fatalf("run_id mismatch: %#v", payload) + } + } + } + if !found { + t.Fatal("expected pending ask_user_form message") + } +} +``` + +- [ ] **Step 2: Run the worker/app test to confirm no message is persisted yet** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/app -run TestAskUserFormCreatesPendingThreadMessage +``` + +Expected: + +```text +FAIL ... expected pending ask_user_form message +``` + +- [ ] **Step 3: Add repository helpers for creating and looking up form messages** + +```go +// src/services/api/internal/data/messages_repo.go +type AskUserFormMessage struct { + RunID uuid.UUID `json:"run_id"` + RequestID string `json:"request_id"` + DisplayMode string `json:"display_mode"` + Message string `json:"message"` + Schema json.RawMessage `json:"schema"` + Status string `json:"status"` + Answers json.RawMessage `json:"answers,omitempty"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` +} + +func (r *MessageRepository) CreateAskUserFormMessage( + ctx context.Context, + accountID uuid.UUID, + threadID uuid.UUID, + runID uuid.UUID, + requestID string, + prompt string, + schema json.RawMessage, +) (Message, error) { + content := AskUserFormMessage{ + RunID: runID, + RequestID: requestID, + DisplayMode: "form", + Message: prompt, + Schema: schema, + Status: "pending", + } + contentJSON, _ := json.Marshal(map[string]any{ + "kind": "ask_user_form", + "display_mode": content.DisplayMode, + "run_id": content.RunID.String(), + "request_id": content.RequestID, + "message": content.Message, + "schema": json.RawMessage(content.Schema), + "status": content.Status, + "answers": nil, + "submitted_at": nil, + }) + metadataJSON, _ := json.Marshal(map[string]string{"run_id": runID.String()}) + return r.CreateStructuredWithMetadata(ctx, accountID, threadID, "assistant", prompt, contentJSON, metadataJSON, nil) +} + +func (r *MessageRepository) FindAskUserFormMessage( + ctx context.Context, + accountID uuid.UUID, + threadID uuid.UUID, + runID uuid.UUID, + requestID string, +) (*Message, error) { + // Query messages where metadata_json->>'run_id' matches and content_json->>'request_id' matches. +} +``` + +- [ ] **Step 4: Persist form-mode requests when `run.input_requested` events are appended** + +```go +// src/services/worker/internal/pipeline/handler_agent_loop.go +if ev.Type == "run.input_requested" { + displayMode, _ := ev.DataJSON["display_mode"].(string) + if strings.TrimSpace(displayMode) == "form" { + requestID, _ := ev.DataJSON["request_id"].(string) + prompt, _ := ev.DataJSON["message"].(string) + schemaJSON, _ := json.Marshal(ev.DataJSON["requestedSchema"]) + if _, err := messagesRepo.FindAskUserFormMessage(ctx, w.run.AccountID, w.run.ThreadID, runID, requestID); err != nil { + return err + } + if _, err := messagesRepo.CreateAskUserFormMessage(ctx, w.run.AccountID, w.run.ThreadID, runID, requestID, prompt, schemaJSON); err != nil { + return err + } + } +} +``` + +```go +// src/services/worker/internal/app/composition_desktop.go +if ev.Type == "run.input_requested" { + displayMode, _ := ev.DataJSON["display_mode"].(string) + if strings.TrimSpace(displayMode) == "form" { + requestID, _ := ev.DataJSON["request_id"].(string) + prompt, _ := ev.DataJSON["message"].(string) + schemaJSON, _ := json.Marshal(ev.DataJSON["requestedSchema"]) + if _, err := w.messagesRepo.CreateAskUserFormMessage(ctx, run.AccountID, run.ThreadID, runID, requestID, prompt, schemaJSON); err != nil { + return err + } + } +} +``` + +- [ ] **Step 5: Re-run the pending-message test** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/app -run TestAskUserFormCreatesPendingThreadMessage +``` + +Expected: + +```text +ok arkloop/services/worker/internal/app ... +``` + +- [ ] **Step 6: Commit the pending-message persistence change** + +```bash +git add src/services/api/internal/data/messages_repo.go \ + src/services/worker/internal/pipeline/handler_agent_loop.go \ + src/services/worker/internal/app/composition_desktop.go \ + src/services/worker/internal/app/composition_desktop_test.go +git commit -m "feat: persist ask_user form requests in thread messages" +``` + +### Task 3: Update `/runs/{id}/input` to Finalize Form Messages + +**Files:** +- Modify: `src/services/api/internal/data/messages_repo.go` +- Modify: `src/services/api/internal/http/conversationapi/v1_runs.go` +- Test: `src/services/api/internal/data/runs_repo_desktop_test.go` + +- [ ] **Step 1: Write failing API tests for submit and dismiss transitions** + +```go +func TestProvideInputMarksAskUserFormSubmitted(t *testing.T) { + // Seed a pending ask_user_form message tied to run_id + request_id. + // Call the submit input handler with {"region":"us-east-1"}. + // Reload the message and assert status=submitted and answers preserved. +} + +func TestProvideInputMarksAskUserFormDismissed(t *testing.T) { + // Seed the same pending form. + // Submit {} and assert status=dismissed with no editable pending state left. +} +``` + +- [ ] **Step 2: Run the API tests to verify the message remains pending** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/api && go test ./internal/data ./internal/http/conversationapi -run 'TestProvideInputMarksAskUserFormSubmitted|TestProvideInputMarksAskUserFormDismissed' +``` + +Expected: + +```text +FAIL ... status = pending, want submitted +FAIL ... status = pending, want dismissed +``` + +- [ ] **Step 3: Add repository helpers for form status transitions** + +```go +// src/services/api/internal/data/messages_repo.go +func (r *MessageRepository) UpdateAskUserFormMessageStatus( + ctx context.Context, + accountID uuid.UUID, + threadID uuid.UUID, + messageID uuid.UUID, + status string, + answers json.RawMessage, + submittedAt *time.Time, +) (Message, error) { + var payload map[string]any + if err := json.Unmarshal(existing.ContentJSON, &payload); err != nil { + return Message{}, err + } + payload["status"] = status + payload["answers"] = json.RawMessage(answers) + payload["submitted_at"] = submittedAt + nextContentJSON, _ := json.Marshal(payload) + return r.UpdateStructuredContent(ctx, accountID, threadID, messageID, strings.TrimSpace(existing.Content), nextContentJSON) +} +``` + +- [ ] **Step 4: Update the submit-input handler to finalize the persisted card** + +```go +// src/services/api/internal/http/conversationapi/v1_runs.go +if _, err := txRepo.ProvideInput(r.Context(), run.ID, body.Content, traceID); err != nil { + // existing error handling +} + + messageRepoTx := messageRepo.WithTx(tx) + pendingForm, err := messageRepoTx.FindLatestPendingAskUserFormByRun(r.Context(), run.AccountID, run.ThreadID, run.ID) + if err != nil { + return writeInternalError(w, traceID, err) + } + if pendingForm != nil { + now := time.Now().UTC() + status := "submitted" + trimmed := strings.TrimSpace(body.Content) + answers := json.RawMessage(trimmed) + if trimmed == "" || trimmed == "{}" { + status = "dismissed" + answers = nil + } + if _, err := messageRepoTx.UpdateAskUserFormMessageStatus(r.Context(), run.AccountID, run.ThreadID, pendingForm.ID, status, answers, &now); err != nil { + return writeInternalError(w, traceID, err) + } + } +``` + +- [ ] **Step 5: Re-run the API transition tests** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/api && go test ./internal/data ./internal/http/conversationapi -run 'TestProvideInputMarksAskUserFormSubmitted|TestProvideInputMarksAskUserFormDismissed' +``` + +Expected: + +```text +ok arkloop/services/api/internal/data ... +ok arkloop/services/api/internal/http/conversationapi ... +``` + +- [ ] **Step 6: Commit the submit-state update** + +```bash +git add src/services/api/internal/data/messages_repo.go \ + src/services/api/internal/http/conversationapi/v1_runs.go \ + src/services/api/internal/data/runs_repo_desktop_test.go \ + src/services/api/internal/http/conversationapi/v1_messages.go \ + src/services/api/internal/http/conversationapi/message_content.go +git commit -m "feat: finalize ask_user form messages on input" +``` + +### Task 4: Expire Pending Form Messages on Terminal Run End + +**Files:** +- Modify: `src/services/api/internal/data/messages_repo.go` +- Modify: `src/services/worker/internal/pipeline/handler_agent_loop.go` +- Modify: `src/services/worker/internal/app/composition_desktop.go` +- Test: `src/services/worker/internal/app/composition_desktop_readonly_test.go` + +- [ ] **Step 1: Write a failing terminal-state test for expiring leftover pending forms** + +```go +func TestCompletedRunExpiresPendingAskUserForm(t *testing.T) { + // Seed a pending ask_user_form message and then append a terminal event for the run. + // Reload the message and assert status=expired. +} +``` + +- [ ] **Step 2: Run the terminal-state test to confirm pending cards are not finalized** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/app -run TestCompletedRunExpiresPendingAskUserForm +``` + +Expected: + +```text +FAIL ... status = pending, want expired +``` + +- [ ] **Step 3: Add a repository helper to bulk-expire pending form messages for a run** + +```go +// src/services/api/internal/data/messages_repo.go +func (r *MessageRepository) ExpirePendingAskUserFormsByRun( + ctx context.Context, + accountID uuid.UUID, + threadID uuid.UUID, + runID uuid.UUID, +) error { + msgs, err := r.ListPendingAskUserFormsByRun(ctx, accountID, threadID, runID) + if err != nil { + return err + } + for _, msg := range msgs { + if _, err := r.UpdateAskUserFormMessageStatus(ctx, accountID, threadID, msg.ID, "expired", nil, nil); err != nil { + return err + } + } + return nil +} +``` + +- [ ] **Step 4: Call the expiry helper when terminal events are appended** + +```go +// src/services/worker/internal/pipeline/handler_agent_loop.go +if status, ok := TerminalStatuses[ev.Type]; ok { + // existing status update logic... + if err := messagesRepo.ExpirePendingAskUserFormsByRun(ctx, w.run.AccountID, w.run.ThreadID, runID); err != nil { + return err + } +} +``` + +```go +// src/services/worker/internal/app/composition_desktop.go +if status, ok := desktopTerminalStatuses[ev.Type]; ok { + // existing desktop terminal logic... + if err := w.messagesRepo.ExpirePendingAskUserFormsByRun(ctx, run.AccountID, run.ThreadID, runID); err != nil { + return err + } +} +``` + +- [ ] **Step 5: Re-run the terminal-state test** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/app -run TestCompletedRunExpiresPendingAskUserForm +``` + +Expected: + +```text +ok arkloop/services/worker/internal/app ... +``` + +- [ ] **Step 6: Commit the expiry logic** + +```bash +git add src/services/api/internal/data/messages_repo.go \ + src/services/worker/internal/pipeline/handler_agent_loop.go \ + src/services/worker/internal/app/composition_desktop.go \ + src/services/worker/internal/app/composition_desktop_readonly_test.go +git commit -m "feat: expire pending ask_user form messages on run end" +``` + +### Task 5: Widen Web Message Types for Structured Form Cards + +**Files:** +- Modify: `src/apps/web/src/api.ts` +- Modify: `src/apps/web/src/agent-ui/contract.ts` +- Modify: `src/apps/web/src/agent-ui/arkloop-adapter.ts` +- Modify: `src/apps/web/src/messageContent.ts` +- Test: `src/apps/web/src/__tests__/chatPageLoading.test.tsx` + +- [ ] **Step 1: Write failing Web tests for `ask_user_form` message decoding** + +```ts +it('maps ask_user_form content_json into an agent message without dropping the custom payload', () => { + const apiMessage = { + id: 'm1', + account_id: 'a1', + thread_id: 't1', + created_by_user_id: 'u1', + role: 'assistant', + content: 'Fill release checklist', + content_json: { + kind: 'ask_user_form', + display_mode: 'form', + request_id: 'call_1', + run_id: 'run_1', + message: 'Fill release checklist', + schema: { properties: { version: { type: 'string' } } }, + status: 'pending', + answers: null, + submitted_at: null, + }, + created_at: '2026-05-25T00:00:00Z', + } + + const agentMessage = toAgentMessage(apiMessage as MessageResponse) + expect(agentMessage.contentJson).toMatchObject({ kind: 'ask_user_form', status: 'pending' }) +}) +``` + +- [ ] **Step 2: Run the Web test to confirm `MessageContent` rejects the custom shape** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm test -- src/__tests__/chatPageLoading.test.tsx +``` + +Expected: + +```text +FAIL ... Property 'parts' does not exist +``` + +- [ ] **Step 3: Convert message content types from a single `parts` object to a tagged union** + +```ts +// src/apps/web/src/api.ts +export type AskUserFormContent = { + kind: 'ask_user_form' + display_mode: 'form' + request_id: string + run_id: string + tool_call_id?: string + message: string + schema: RequestedSchema + status: 'pending' | 'submitted' | 'dismissed' | 'expired' + answers: Record | null + submitted_at: string | null +} + +export type MessageContent = + | { parts: MessageContentPart[] } + | AskUserFormContent +``` + +```ts +// src/apps/web/src/agent-ui/contract.ts +export type AgentAskUserFormContent = { + kind: 'ask_user_form' + displayMode: 'form' + requestId: string + runId: string + toolCallId?: string + message: string + schema: RequestedSchema + status: 'pending' | 'submitted' | 'dismissed' | 'expired' + answers: Record | null + submittedAt: string | null +} + +export type AgentMessageContent = + | { parts: AgentMessageContentPart[] } + | AgentAskUserFormContent +``` + +- [ ] **Step 4: Update adapters and helper functions to branch on `parts` vs `kind`** + +```ts +// src/apps/web/src/agent-ui/arkloop-adapter.ts +function toAgentContent(content: MessageContent | undefined): AgentMessageContent | undefined { + if (!content) return undefined + if ('parts' in content) return { parts: content.parts.map(toAgentContentPart) } + return { + kind: 'ask_user_form', + displayMode: 'form', + requestId: content.request_id, + runId: content.run_id, + toolCallId: content.tool_call_id, + message: content.message, + schema: content.schema, + status: content.status, + answers: content.answers, + submittedAt: content.submitted_at, + } +} +``` + +```ts +// src/apps/web/src/messageContent.ts +export function messageTextContent(message: Pick): string { + if (message.contentJson && 'kind' in message.contentJson && message.contentJson.kind === 'ask_user_form') { + return message.contentJson.message + } + if (message.contentJson && 'parts' in message.contentJson && message.contentJson.parts.length) { + return message.contentJson.parts + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join('\n\n') + .trim() + } + return extractLegacyFilesFromContent(message.content).text +} +``` + +- [ ] **Step 5: Re-run the Web type/adaptor test** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm test -- src/__tests__/chatPageLoading.test.tsx +``` + +Expected: + +```text +✓ ... ask_user_form content_json ... +``` + +- [ ] **Step 6: Commit the Web type-union change** + +```bash +git add src/apps/web/src/api.ts \ + src/apps/web/src/agent-ui/contract.ts \ + src/apps/web/src/agent-ui/arkloop-adapter.ts \ + src/apps/web/src/messageContent.ts \ + src/apps/web/src/__tests__/chatPageLoading.test.tsx +git commit -m "refactor: support ask_user form message content" +``` + +### Task 6: Render Form Cards in the Chat Stream and Keep Inline Mode Unchanged + +**Files:** +- Create: `src/apps/web/src/components/AskUserFormMessageCard.tsx` +- Modify: `src/apps/web/src/components/MessageList.tsx` +- Modify: `src/apps/web/src/components/ChatView.tsx` +- Modify: `src/apps/web/src/hooks/useThreadSseEffect.ts` +- Modify: `src/apps/web/src/hooks/useChatActions.ts` +- Test: `src/apps/web/src/__tests__/userInputCard.test.tsx` +- Test: `src/apps/web/src/__tests__/chatPageLoading.test.tsx` + +- [ ] **Step 1: Write failing UI tests for pending, submitted, and inline-compat modes** + +```ts +it('renders a pending ask_user_form card inside the message list', () => { + render() + expect(screen.getByText('Fill release checklist')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: /version/i })).toBeInTheDocument() +}) + +it('renders submitted ask_user_form cards as read-only full-field output', () => { + render() + expect(screen.getByText('version')).toBeInTheDocument() + expect(screen.getByText('1.2.3')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /submit/i })).not.toBeInTheDocument() +}) + +it('keeps inline ask_user requests on the temporary composer card path', async () => { + // Simulate run.input_requested with display_mode=inline. + // Assert pendingUserInput is set and MessageList does not render an ask_user_form message. +}) +``` + +- [ ] **Step 2: Run the Web UI tests to capture current failures** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm test -- src/__tests__/userInputCard.test.tsx src/__tests__/chatPageLoading.test.tsx +``` + +Expected: + +```text +FAIL ... Unable to find pending ask_user_form card +FAIL ... expected read-only submitted display +``` + +- [ ] **Step 3: Build the dedicated in-stream form card component** + +```tsx +// src/apps/web/src/components/AskUserFormMessageCard.tsx +export function AskUserFormMessageCard({ + content, + activeRunId, + onSubmit, +}: { + content: AgentAskUserFormContent + activeRunId: string | null + onSubmit: (requestId: string, answers: Record) => Promise +}) { + const editable = content.status === 'pending' && activeRunId === content.runId + + if (!editable) { + return ( +
+

{content.message}

+ {Object.entries(content.answers ?? {}).map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+ ) + } + + return ( + onSubmit(content.requestId, response.answers)} + onDismiss={() => onSubmit(content.requestId, {})} + /> + ) +} +``` + +- [ ] **Step 4: Switch MessageList and ChatView to route form-mode through the message stream** + +```tsx +// src/apps/web/src/components/MessageList.tsx +if ( + msg.role === 'assistant' && + msg.contentJson && + 'kind' in msg.contentJson && + msg.contentJson.kind === 'ask_user_form' +) { + return ( + + ) +} +``` + +```ts +// src/apps/web/src/hooks/useThreadSseEffect.ts +if (event.type === 'input-request') { + const data = agentEventDataRecord(event.data) + const displayMode = typeof data?.display_mode === 'string' ? data.display_mode : 'inline' + if (displayMode === 'form') { + setAwaitingInput(true) + continue + } + // existing inline pendingUserInput behavior remains here +} +``` + +```ts +// src/apps/web/src/hooks/useChatActions.ts +const handleAskUserFormSubmit = useCallback(async (requestId: string, answers: Record) => { + if (!activeRunId) return + await agentClient.provideInput(activeRunId, JSON.stringify(answers)) + setPendingUserInput(null) +}, [agentClient, activeRunId, setPendingUserInput]) +``` + +- [ ] **Step 5: Re-run the Web UI tests and a type-check** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm test -- src/__tests__/userInputCard.test.tsx src/__tests__/chatPageLoading.test.tsx +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm type-check +``` + +Expected: + +```text +✓ ... pending ask_user_form card inside the message list +✓ ... submitted ask_user_form cards as read-only full-field output +✓ ... keeps inline ask_user requests on the temporary composer card path +``` + +```text +Done in ... +``` + +- [ ] **Step 6: Commit the renderer and regression coverage** + +```bash +git add src/apps/web/src/components/AskUserFormMessageCard.tsx \ + src/apps/web/src/components/MessageList.tsx \ + src/apps/web/src/components/ChatView.tsx \ + src/apps/web/src/hooks/useThreadSseEffect.ts \ + src/apps/web/src/hooks/useChatActions.ts \ + src/apps/web/src/__tests__/userInputCard.test.tsx \ + src/apps/web/src/__tests__/chatPageLoading.test.tsx +git commit -m "feat: render ask_user forms in chat history" +``` + +### Task 7: Run End-to-End Regression Checks + +**Files:** +- Modify: none +- Test: existing touched test files across worker, api, and web + +- [ ] **Step 1: Run the consolidated Go regression suite** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/services/worker && go test ./internal/tools/builtin/askuser ./internal/agent ./internal/executor ./internal/app ./internal/pipeline +cd /Users/huhui/Projects/Arkloop/src/services/api && go test ./internal/data ./internal/http/conversationapi +``` + +Expected: + +```text +ok arkloop/services/worker/internal/tools/builtin/askuser ... +ok arkloop/services/worker/internal/agent ... +ok arkloop/services/worker/internal/executor ... +ok arkloop/services/worker/internal/app ... +ok arkloop/services/worker/internal/pipeline ... +ok arkloop/services/api/internal/data ... +ok arkloop/services/api/internal/http/conversationapi ... +``` + +- [ ] **Step 2: Run the consolidated Web regression suite** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm test -- src/__tests__/userInputCard.test.tsx src/__tests__/chatPageLoading.test.tsx +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm type-check +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm lint +``` + +Expected: + +```text +✓ all selected vitest cases passed +``` + +```text +Found 0 errors. +``` + +```text +Done with no ESLint errors. +``` + +- [ ] **Step 3: Manual verification in the browser** + +Run: + +```bash +cd /Users/huhui/Projects/Arkloop/src/apps/web && pnpm dev +``` + +Verify: + +```text +1. A normal inline ask_user request still appears above the composer and disappears after submit. +2. A form-mode ask_user request appears as a chat message card. +3. Submitting the form keeps the card in place and turns it read-only. +4. Refreshing the page still shows the submitted card with all field values. +5. A timed-out form request reappears as a non-editable expired card. +``` + +- [ ] **Step 4: Commit any final fixes from the regression pass** + +```bash +git add src/services/worker/internal/tools/builtin/askuser/spec.go \ + src/services/worker/internal/tools/builtin/askuser/executor.go \ + src/services/worker/internal/agent/loop.go \ + src/services/worker/internal/pipeline/handler_agent_loop.go \ + src/services/worker/internal/app/composition_desktop.go \ + src/services/api/internal/data/messages_repo.go \ + src/services/api/internal/http/conversationapi/v1_runs.go \ + src/apps/web/src/api.ts \ + src/apps/web/src/agent-ui/contract.ts \ + src/apps/web/src/agent-ui/arkloop-adapter.ts \ + src/apps/web/src/messageContent.ts \ + src/apps/web/src/components/AskUserFormMessageCard.tsx \ + src/apps/web/src/components/MessageList.tsx \ + src/apps/web/src/components/ChatView.tsx \ + src/apps/web/src/hooks/useThreadSseEffect.ts \ + src/apps/web/src/hooks/useChatActions.ts \ + src/apps/web/src/__tests__/userInputCard.test.tsx \ + src/apps/web/src/__tests__/chatPageLoading.test.tsx +git commit -m "test: close ask_user form in chat regressions" +``` + +## Self-Review Notes + +- Spec coverage: the plan covers explicit `display_mode`, pending-message persistence, submit/dismiss/expired transitions, message-stream rendering, refresh durability, and inline compatibility. +- Placeholder scan: no `TBD`, `TODO`, or deferred implementation notes remain. +- Type consistency: the plan consistently uses `display_mode=form`, `kind=ask_user_form`, statuses `pending/submitted/dismissed/expired`, and the existing `/v1/runs/{id}/input` submission path. diff --git a/src/apps/web/src/agent-ui/arkloop-adapter.ts b/src/apps/web/src/agent-ui/arkloop-adapter.ts index f4f4c20d1..ec7e7f0b2 100644 --- a/src/apps/web/src/agent-ui/arkloop-adapter.ts +++ b/src/apps/web/src/agent-ui/arkloop-adapter.ts @@ -9,6 +9,7 @@ import { retryMessage, type MessageContent, type MessageContentPart, + type AskUserFormContent, type MessageResponse, type RunEvent, } from '../api' @@ -21,6 +22,7 @@ import type { AgentMessageAttachmentRef, AgentMessageContent, AgentMessageContentPart, + AgentAskUserFormContent, AgentRun, AgentOpenEventStreamOptions, AgentUIEvent, @@ -103,13 +105,57 @@ function toArkloopContentPart(part: AgentMessageContentPart): MessageContentPart } } +function isAskUserFormContent(content: MessageContent): content is AskUserFormContent { + return 'kind' in content && content.kind === 'ask_user_form' +} + function toAgentContent(content: MessageContent | undefined): AgentMessageContent | undefined { - if (!content?.parts?.length) return undefined + if (!content) return undefined + if (isAskUserFormContent(content)) { + return { + kind: 'ask_user_form', + displayMode: 'form', + requestId: content.request_id, + runId: content.run_id, + toolCallId: content.tool_call_id, + message: content.message, + schema: { + properties: content.schema.properties as Record, + required: content.schema.required, + _fieldOrder: content.schema._fieldOrder, + displayMode: content.schema.display_mode, + }, + status: content.status, + answers: content.answers, + submittedAt: content.submitted_at, + } as AgentAskUserFormContent + } + if (!content.parts?.length) return undefined return { parts: content.parts.map(toAgentContentPart) } } function toArkloopContent(content: AgentMessageContent | undefined): MessageContent | undefined { - if (!content?.parts?.length) return undefined + if (!content) return undefined + if ('kind' in content && content.kind === 'ask_user_form') { + return { + kind: 'ask_user_form', + display_mode: 'form', + request_id: content.requestId, + run_id: content.runId, + tool_call_id: content.toolCallId, + message: content.message, + schema: { + properties: content.schema.properties, + required: content.schema.required, + _fieldOrder: content.schema._fieldOrder, + display_mode: content.schema.displayMode, + }, + status: content.status, + answers: content.answers, + submitted_at: content.submittedAt, + } as AskUserFormContent + } + if (!('parts' in content) || !content.parts?.length) return undefined return { parts: content.parts.map(toArkloopContentPart) } } diff --git a/src/apps/web/src/agent-ui/contract.ts b/src/apps/web/src/agent-ui/contract.ts index 911799006..72360abc0 100644 --- a/src/apps/web/src/agent-ui/contract.ts +++ b/src/apps/web/src/agent-ui/contract.ts @@ -12,10 +12,28 @@ export type AgentMessageContentPart = | { type: 'image'; attachment: AgentMessageAttachmentRef } | { type: 'file'; attachment: AgentMessageAttachmentRef; extractedText: string } -export type AgentMessageContent = { - parts: AgentMessageContentPart[] +export type AgentAskUserFormContent = { + kind: 'ask_user_form' + displayMode: 'form' + requestId: string + runId: string + toolCallId?: string + message: string + schema: { + properties: Record + required?: string[] + _fieldOrder?: string[] + displayMode?: string + } + status: 'pending' | 'submitted' | 'dismissed' | 'expired' + answers: Record | null + submittedAt: string | null } +export type AgentMessageContent = + | { parts: AgentMessageContentPart[] } + | AgentAskUserFormContent + export type AgentProviderMetadata = Record export type AgentUIDataTypes = Record diff --git a/src/apps/web/src/agent-ui/index.ts b/src/apps/web/src/agent-ui/index.ts index 1ce2188ff..046676ec2 100644 --- a/src/apps/web/src/agent-ui/index.ts +++ b/src/apps/web/src/agent-ui/index.ts @@ -1,4 +1,5 @@ export type { + AgentAskUserFormContent, AgentBackendAdapter, AgentChatRequestOptions, AgentClient, diff --git a/src/apps/web/src/api.ts b/src/apps/web/src/api.ts index f401208a8..b3b0648b3 100644 --- a/src/apps/web/src/api.ts +++ b/src/apps/web/src/api.ts @@ -937,10 +937,28 @@ export type MessageContentPart = | { type: 'image'; attachment: MessageAttachmentRef } | { type: 'file'; attachment: MessageAttachmentRef; extracted_text: string } -export type MessageContent = { - parts: MessageContentPart[] +export type AskUserFormContent = { + kind: 'ask_user_form' + display_mode: 'form' + request_id: string + run_id: string + tool_call_id?: string + message: string + schema: { + properties: Record + required?: string[] + _fieldOrder?: string[] + display_mode?: string + } + status: 'pending' | 'submitted' | 'dismissed' | 'expired' + answers: Record | null + submitted_at: string | null } +export type MessageContent = + | { parts: MessageContentPart[] } + | AskUserFormContent + export type CreateMessageRequest = { content?: string content_json?: MessageContent diff --git a/src/apps/web/src/components/AskUserFormMessageCard.tsx b/src/apps/web/src/components/AskUserFormMessageCard.tsx new file mode 100644 index 000000000..a8aae1c4d --- /dev/null +++ b/src/apps/web/src/components/AskUserFormMessageCard.tsx @@ -0,0 +1,124 @@ +import { useCallback, useState } from 'react' +import { ChevronDown, ChevronUp } from 'lucide-react' +import type { AgentAskUserFormContent } from '../agent-ui' +import type { FieldValue } from '../userInputTypes' +import UserInputCard from './UserInputCard' + +interface Props { + content: AgentAskUserFormContent + activeRunId: string | null + onSubmit: (requestId: string, answers: Record) => Promise + onDismiss: (requestId: string) => Promise +} + +function formatFieldValue(value: unknown): string { + if (value === null || value === undefined) return '-' + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + if (Array.isArray(value)) return value.map(String).join(', ') + return String(value) +} + +function SubmittedAnswersView({ content }: { content: AgentAskUserFormContent }) { + const [expanded, setExpanded] = useState(false) + const answers = content.answers ?? {} + const keys = content.schema._fieldOrder ?? Object.keys(answers) + const statusLabel = content.status === 'submitted' ? 'Submitted' : content.status === 'dismissed' ? 'Dismissed' : 'Expired' + const statusColor = content.status === 'submitted' ? 'var(--c-status-success)' : 'var(--c-text-muted)' + + return ( +
+
+

+ {content.message} +

+ + {statusLabel} + +
+ + {keys.length > 0 && ( + + )} + + {expanded && ( +
+ {keys.map((key: string) => { + const fieldSchema = content.schema.properties[key] + const title = (fieldSchema && typeof fieldSchema === 'object' && 'title' in fieldSchema) + ? (fieldSchema.title as string) ?? key + : key + const value = answers[key] + return ( +
+ + {title} + + + {formatFieldValue(value)} + +
+ ) + })} + {content.submittedAt && ( +
+ {new Date(content.submittedAt).toLocaleString()} +
+ )} +
+ )} +
+ ) +} + +export default function AskUserFormMessageCard({ content, activeRunId, onSubmit, onDismiss }: Props) { + const isPending = content.status === 'pending' + const isEditable = isPending && activeRunId === content.runId + + const handleSubmit = useCallback(async (response: { type: 'user_input_response'; request_id: string; answers: Record }) => { + await onSubmit(response.request_id, response.answers) + }, [onSubmit]) + + const handleDismiss = useCallback(async () => { + await onDismiss(content.requestId) + }, [onDismiss, content.requestId]) + + if (isEditable) { + return ( + , + required: content.schema.required, + _fieldOrder: content.schema._fieldOrder, + }, + }} + onSubmit={handleSubmit} + onDismiss={handleDismiss} + disabled={!activeRunId} + /> + ) + } + + return +} diff --git a/src/apps/web/src/components/ChatView.tsx b/src/apps/web/src/components/ChatView.tsx index 7bcbc8919..aacac5d4b 100644 --- a/src/apps/web/src/components/ChatView.tsx +++ b/src/apps/web/src/components/ChatView.tsx @@ -1244,6 +1244,8 @@ export const ChatView = memo(function ChatView() { handleCheckInSubmit, handleUserInputSubmit, handleUserInputDismiss, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleAsrError, handleArtifactAction, } = useChatActions({ scrollToBottom: activateAnchor, onSelectForkAnchor: selectForkAnchor }) @@ -3609,6 +3611,8 @@ export const ChatView = memo(function ChatView() { handleEditMessage={handleEditMessage} handleFork={handleFork} handleArtifactAction={handleArtifactAction} + handleAskUserFormSubmit={handleAskUserFormSubmit} + handleAskUserFormDismiss={handleAskUserFormDismiss} openDocumentPanel={openDocumentPanel} openResourcePanel={openResourcePanel} openCodePanel={openCodePanel} @@ -3630,6 +3634,8 @@ export const ChatView = memo(function ChatView() { currentRunCopHeaderOverride, displayedMessages, handleArtifactAction, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleEditMessage, handleFork, handleRetryUserMessage, diff --git a/src/apps/web/src/components/MessageList.tsx b/src/apps/web/src/components/MessageList.tsx index cca375ff4..90ab534a8 100644 --- a/src/apps/web/src/components/MessageList.tsx +++ b/src/apps/web/src/components/MessageList.tsx @@ -1,5 +1,6 @@ import { memo, Fragment, forwardRef, useCallback, useImperativeHandle, useMemo, type ComponentProps } from 'react' import { MessageBubble } from './MessageBubble' +import AskUserFormMessageCard from './AskUserFormMessageCard' import { CopTimeline, type WebSearchPhaseStep } from './cop-timeline/CopTimeline' import { CopSegmentBlocks } from './CopSegmentBlocks' import { TopLevelCopToolBlock } from './TopLevelCopToolBlock' @@ -59,6 +60,8 @@ export type MessageListProps = { handleEditMessage: (message: AgentMessage, newContent: string) => void handleFork: (messageId: string) => Promise handleArtifactAction: ComponentProps['onAction'] + handleAskUserFormSubmit?: (requestId: string, answers: Record) => Promise + handleAskUserFormDismiss?: (requestId: string) => Promise openDocumentPanel: (artifact: ArtifactRef, options?: { trigger?: HTMLElement | null; artifacts?: ArtifactRef[]; runId?: string }) => void openResourcePanel: (resource: ResourceRef, options?: { trigger?: HTMLElement | null; artifacts?: ArtifactRef[]; runId?: string }) => void openCodePanel: (ce: CodeExecution) => void @@ -92,6 +95,8 @@ export const MessageList = memo(forwardRef( handleEditMessage, handleFork, handleArtifactAction, + handleAskUserFormSubmit, + handleAskUserFormDismiss, openDocumentPanel, openResourcePanel, openCodePanel, @@ -311,6 +316,33 @@ export const MessageList = memo(forwardRef( ) if (hideTerminalRunMessage) return null + // Render ask_user_form messages as form cards + if ( + msg.role === 'assistant' && + msg.contentJson && + 'kind' in msg.contentJson && + msg.contentJson.kind === 'ask_user_form' && + handleAskUserFormSubmit && + handleAskUserFormDismiss + ) { + return ( +
+ +
+ ) + } + const msgMeta = msg.role === 'assistant' ? meta.getMeta(msg.id) : undefined const resolvedSources = msg.role === 'assistant' ? resolvedMessageSources.get(msg.id) : undefined const isCurrentTerminalRunMessage = @@ -607,6 +639,8 @@ export const MessageList = memo(forwardRef( createShareForMessage, currentRunCopHeaderOverride, handleArtifactAction, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleFork, hasCurrentRunHandoffUi, isSearchThread, @@ -625,6 +659,7 @@ export const MessageList = memo(forwardRef( openSourcePanel, privateThreadIds, resolvedMessageSources, + run.activeRunId, sending, setRunDetailPanelRunId, sharedMessageId, diff --git a/src/apps/web/src/components/SharePage.tsx b/src/apps/web/src/components/SharePage.tsx index 893ed2c5f..aa056e97d 100644 --- a/src/apps/web/src/components/SharePage.tsx +++ b/src/apps/web/src/components/SharePage.tsx @@ -212,7 +212,7 @@ export function SharePage() { id: msg.id, role: msg.role === 'system' || msg.role === 'user' || msg.role === 'assistant' ? msg.role : 'assistant', content: msg.content, - contentJson: msg.content_json + contentJson: msg.content_json && 'parts' in msg.content_json ? { parts: msg.content_json.parts.map((part) => { if (part.type === 'text') return part diff --git a/src/apps/web/src/components/conversation-graph/buildConversationGraph.ts b/src/apps/web/src/components/conversation-graph/buildConversationGraph.ts index 517aecde8..53bb07eb0 100644 --- a/src/apps/web/src/components/conversation-graph/buildConversationGraph.ts +++ b/src/apps/web/src/components/conversation-graph/buildConversationGraph.ts @@ -17,9 +17,8 @@ export type ConversationGraphFlowNode = Node part.type === 'text' && 'text' in part ? part.text : '') .join('') .trim() diff --git a/src/apps/web/src/contexts/thread-list.tsx b/src/apps/web/src/contexts/thread-list.tsx index f10cb5a2e..df3b7de5a 100644 --- a/src/apps/web/src/contexts/thread-list.tsx +++ b/src/apps/web/src/contexts/thread-list.tsx @@ -109,7 +109,7 @@ function truncateNotificationText(value: string, limit: number): string { } function messageUserText(message: MessageResponse): string { - if (message.content_json?.parts?.length) { + if (message.content_json && 'parts' in message.content_json && message.content_json.parts?.length) { return message.content_json.parts .filter((part) => part.type === 'text') .map((part) => part.text) diff --git a/src/apps/web/src/hooks/useChatActions.ts b/src/apps/web/src/hooks/useChatActions.ts index 00fcf0c41..d311e5a8c 100644 --- a/src/apps/web/src/hooks/useChatActions.ts +++ b/src/apps/web/src/hooks/useChatActions.ts @@ -234,8 +234,10 @@ export function useChatActions({ scrollToBottom, onSelectForkAnchor }: UseChatAc setTerminalRunHandoffStatus(null) setTerminalRunCoveredRunIds([]) try { - const nonTextParts = original.contentJson?.parts?.filter((part) => part.type !== 'text') ?? [] - const newContentJson: AgentMessageContent | undefined = original.contentJson + const nonTextParts = original.contentJson && 'parts' in original.contentJson + ? original.contentJson.parts.filter((part) => part.type !== 'text') + : [] + const newContentJson: AgentMessageContent | undefined = original.contentJson && 'parts' in original.contentJson ? { parts: [{ type: 'text', text: newContent }, ...nonTextParts] } : undefined const personaKey = readSelectedPersonaKeyFromStorage() ?? undefined @@ -508,6 +510,36 @@ export function useChatActions({ scrollToBottom, onSelectForkAnchor }: UseChatAc } }, [agentClient, activeRunId, onLoggedOut, pendingUserInput, setError, setInjectionBlocked, setPendingUserInput]) + const handleAskUserFormSubmit = useCallback(async (_requestId: string, answers: Record) => { + if (!activeRunId) return + setError(null) + setInjectionBlocked(null) + try { + await agentClient.provideInput(activeRunId, JSON.stringify(answers)) + } catch (err) { + if (isApiError(err) && err.status === 401) { + onLoggedOut() + return + } + setError(normalizeError(err)) + } + }, [agentClient, activeRunId, onLoggedOut, setError, setInjectionBlocked]) + + const handleAskUserFormDismiss = useCallback(async (_requestId: string) => { + if (!activeRunId) return + setError(null) + setInjectionBlocked(null) + try { + await agentClient.provideInput(activeRunId, JSON.stringify({})) + } catch (err) { + if (isApiError(err) && err.status === 401) { + onLoggedOut() + return + } + setError(normalizeError(err)) + } + }, [agentClient, activeRunId, onLoggedOut, setError, setInjectionBlocked]) + const handleAsrError = useCallback((err: unknown) => { if (isApiError(err) && err.status === 401) { onLoggedOut() @@ -563,6 +595,8 @@ export function useChatActions({ scrollToBottom, onSelectForkAnchor }: UseChatAc handleCheckInSubmit, handleUserInputSubmit, handleUserInputDismiss, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleAsrError, handleArtifactAction, } diff --git a/src/apps/web/src/hooks/useThreadSseEffect.ts b/src/apps/web/src/hooks/useThreadSseEffect.ts index 32950b3de..611289da2 100644 --- a/src/apps/web/src/hooks/useThreadSseEffect.ts +++ b/src/apps/web/src/hooks/useThreadSseEffect.ts @@ -750,6 +750,15 @@ export function useThreadSseEffect({ const data = agentEventDataRecord(event.data) const message = data?.message as string | undefined const schema = data?.requestedSchema as RequestedSchema | undefined + const displayMode = typeof data?.display_mode === 'string' ? data.display_mode : 'inline' + + // Form-mode requests are rendered as persistent messages in the stream + // Only set awaitingInput to disable the chat input while waiting + if (displayMode === 'form') { + setAwaitingInput(true) + continue + } + if (message && schema && schema.properties && Object.keys(schema.properties).length > 0) { const safeSchema: RequestedSchema = { ...schema, diff --git a/src/apps/web/src/messageContent.ts b/src/apps/web/src/messageContent.ts index 6f9c12a16..74e19e041 100644 --- a/src/apps/web/src/messageContent.ts +++ b/src/apps/web/src/messageContent.ts @@ -150,7 +150,10 @@ export function extractLegacyFilesFromContent(content: string): { text: string; } export function messageTextContent(message: Pick): string { - if (message.contentJson?.parts?.length) { + if (message.contentJson && 'kind' in message.contentJson && message.contentJson.kind === 'ask_user_form') { + return message.contentJson.message + } + if (message.contentJson && 'parts' in message.contentJson && message.contentJson.parts?.length) { return message.contentJson.parts .filter((part): part is Extract => part.type === 'text') .map((part) => part.text) @@ -161,7 +164,7 @@ export function messageTextContent(message: Pick): AgentMessageContentPart[] { - if (message.contentJson?.parts?.length) { + if (message.contentJson && 'parts' in message.contentJson && message.contentJson.parts?.length) { return message.contentJson.parts.filter((part) => part.type === 'image' || part.type === 'file') } return [] @@ -195,7 +198,13 @@ export function buildMessageRequest(text: string, uploads: UploadedThreadAttachm } export function buildAgentUIParts(contentJson: AgentMessageContent | undefined, content: string): AgentUIMessagePart[] { - if (!contentJson?.parts?.length) { + if (!contentJson) { + return content ? [{ type: 'text', text: content, state: 'done' }] : [] + } + if ('kind' in contentJson && contentJson.kind === 'ask_user_form') { + return [{ type: 'text', text: contentJson.message, state: 'done' }] + } + if (!('parts' in contentJson) || !contentJson.parts?.length) { return content ? [{ type: 'text', text: content, state: 'done' }] : [] } return contentJson.parts.flatMap((part) => { @@ -246,8 +255,10 @@ export function isFilePart(part: AgentMessageContentPart): part is Extract