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/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. diff --git a/src/apps/web/src/__tests__/messageList.askUserForm.test.tsx b/src/apps/web/src/__tests__/messageList.askUserForm.test.tsx new file mode 100644 index 000000000..e75dee17f --- /dev/null +++ b/src/apps/web/src/__tests__/messageList.askUserForm.test.tsx @@ -0,0 +1,231 @@ +import { createRef, type ReactNode } from 'react' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { MessageList } from '../components/MessageList' +import type { AgentMessage } from '../agent-ui' + +vi.mock('../components/MessageBubble', () => ({ + MessageBubble: ({ message, contentOverride }: { message: { content: string }; contentOverride?: string }) => ( +
{contentOverride ?? message.content}
+ ), +})) + +vi.mock('../components/AskUserFormMessageCard', () => ({ + default: ({ content }: { content: { message: string } }) => ( +
{content.message}
+ ), +})) + +vi.mock('../components/WidgetBlock', () => ({ + WidgetBlock: () => null, +})) + +vi.mock('../components/CopSegmentBlocks', () => ({ + CopSegmentBlocks: () => null, +})) + +vi.mock('../components/TopLevelCopToolBlock', () => ({ + TopLevelCopToolBlock: () => null, +})) + +vi.mock('../components/IncognitoDivider', () => ({ + IncognitoDivider: () => null, +})) + +vi.mock('../components/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, +})) + +vi.mock('../components/WorkGroup', () => ({ + WorkGroup: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('../components/cop-timeline/CopTimeline', () => ({ + CopTimeline: () => null, +})) + +vi.mock('../components/messagebubble/AssistantMessage', () => ({ + AssistantActionBar: () => null, +})) + +vi.mock('../contexts/chat-session', () => ({ + useChatSession: () => ({ threadId: 'thread-1', isSearchThread: false }), +})) + +vi.mock('../contexts/run-lifecycle', () => ({ + useRunLifecycle: () => ({ + isStreaming: false, + sending: false, + terminalRunDisplayId: null, + terminalRunHandoffStatus: null, + terminalRunCoveredRunIds: [], + activeRunId: 'run-form', + }), +})) + +vi.mock('../contexts/message-store', () => ({ + isLocalTerminalMessage: () => false, + useMessageStore: () => ({ + messages: [], + userEnterMessageId: null, + }), +})) + +vi.mock('../contexts/message-meta', () => ({ + useMessageMeta: () => ({ + getMeta: () => undefined, + }), +})) + +vi.mock('../contexts/stream', () => ({ + useStream: () => ({ + preserveLiveRunUi: false, + liveAssistantTurn: null, + topLevelCodeExecutions: [], + topLevelSubAgents: [], + topLevelFileOps: [], + topLevelWebFetches: [], + streamingArtifacts: [], + }), +})) + +vi.mock('../contexts/panels', () => ({ + useActiveCodeExecutionId: () => null, + usePanelActions: () => ({ + closePanel: vi.fn(), + openSourcePanel: vi.fn(), + setShareState: vi.fn(), + }), + useShareModalState: () => ({ + sharingMessageId: null, + sharedMessageId: null, + }), +})) + +vi.mock('../contexts/auth', () => ({ + useAuth: () => ({ accessToken: 'token' }), +})) + +vi.mock('../contexts/thread-list', () => ({ + useThreadList: () => ({ privateThreadIds: new Set() }), +})) + +vi.mock('../contexts/LocaleContext', () => ({ + useLocale: () => ({ + t: { + incognitoForkDivider: 'fork', + }, + }), +})) + +vi.mock('../lib/chat-helpers', () => ({ + turnHasCopThinkingItems: () => false, + widgetToolCallIdsPlacedInTurn: () => new Set(), + historicWidgetsForCop: () => [], +})) + +vi.mock('../components/chatSourceResolver', () => ({ + resolveMessageSourcesForRender: () => new Map(), +})) + +vi.mock('../storage', () => ({ + readMessageTerminalStatus: () => null, + readMessageWidgets: () => null, +})) + +vi.mock('../api', () => ({ + createThreadShare: vi.fn(), +})) + +vi.mock('@arkloop/shared/api', () => ({ + apiBaseUrl: () => '', +})) + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useLocation: () => ({ state: null }), + } +}) + +describe('MessageList ask_user_form', () => { + let container: HTMLDivElement + let root: ReturnType + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + it('隐藏当前活跃 pending form 的历史消息行,避免 prompt 重复显示', async () => { + const messages: AgentMessage[] = [ + { + id: 'msg-form', + role: 'assistant', + content: '请补充项目地址', + contentJson: { + kind: 'ask_user_form', + displayMode: 'form', + requestId: 'req-1', + runId: 'run-form', + message: '请补充项目地址', + schema: { + properties: { + project_url: { type: 'string', title: '项目地址' }, + }, + required: ['project_url'], + _fieldOrder: ['project_url'], + }, + status: 'pending', + answers: null, + submittedAt: null, + }, + createdAt: '2026-03-10T00:00:01Z', + parts: [], + streamId: 'run-form', + }, + ] + + await act(async () => { + root.render( + ()} + lastUserPromptRef={createRef()} + lastTurnStartIdx={0} + handleRetryUserMessage={() => {}} + handleEditMessage={() => {}} + handleFork={async () => {}} + handleArtifactAction={() => {}} + handleAskUserFormSubmit={async () => {}} + handleAskUserFormDismiss={async () => {}} + openDocumentPanel={() => {}} + openResourcePanel={() => {}} + openCodePanel={() => {}} + openAgentPanel={() => {}} + showRunDetailButton={false} + sourcePanelMessageId={null} + setRunDetailPanelRunId={() => {}} + currentRunCopHeaderOverride={() => undefined} + clearUserEnterAnimation={() => {}} + messagesOverride={messages} + />, + ) + }) + + expect(container.querySelector('[data-testid="message-bubble"]')).toBeNull() + expect(container.querySelector('[data-testid="ask-user-form-card"]')).toBeNull() + expect(container.textContent).not.toContain('请补充项目地址') + }) +}) 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..8873ac712 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 @@ -316,6 +334,7 @@ export type AgentInputRequestData = { requestId?: string message?: string requestedSchema?: unknown + display_mode?: string } export type AgentSecurityBlockData = { diff --git a/src/apps/web/src/agent-ui/event-data.ts b/src/apps/web/src/agent-ui/event-data.ts index 7df5e7018..045980921 100644 --- a/src/apps/web/src/agent-ui/event-data.ts +++ b/src/apps/web/src/agent-ui/event-data.ts @@ -168,6 +168,7 @@ function normalizeInputRequest(value: unknown): AgentInputRequestData { ...(stringField(record, 'requestId', 'request_id') ? { requestId: stringField(record, 'requestId', 'request_id') } : {}), ...(stringField(record, 'message') ? { message: stringField(record, 'message') } : {}), ...(record && 'requestedSchema' in record ? { requestedSchema: record.requestedSchema } : {}), + ...(stringField(record, 'display_mode') ? { display_mode: stringField(record, 'display_mode') } : {}), } } 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..7f33ff538 --- /dev/null +++ b/src/apps/web/src/components/AskUserFormMessageCard.tsx @@ -0,0 +1,666 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown, ChevronUp } from 'lucide-react' +import type { CSSProperties } from 'react' +import { PillToggle } from '@arkloop/shared' +import type { AgentAskUserFormContent } from '../agent-ui' +import type { FieldSchema, FieldValue } from '../userInputTypes' +import { + isEnumField, + isOneOfField, + isArrayEnumField, + isArrayAnyOfField, + isBooleanField, + isTextField, + isNumberField, +} from '../userInputTypes' +import { useLocale } from '../contexts/LocaleContext' + +interface Props { + content: AgentAskUserFormContent + activeRunId: string | null + onSubmit: (requestId: string, answers: Record) => Promise + onDismiss: (requestId: string) => Promise +} + +function formatFieldValue(value: unknown, t: { yes: string; no: string }): string { + if (value === null || value === undefined) return '-' + if (typeof value === 'boolean') return value ? t.yes : t.no + if (Array.isArray(value)) return value.map(String).join(', ') + return String(value) +} + +function SubmittedAnswersView({ content }: { content: AgentAskUserFormContent }) { + const [expanded, setExpanded] = useState(false) + const { t } = useLocale() + const answers = content.answers ?? {} + const keys = content.schema._fieldOrder ?? Object.keys(answers) + + return ( +
+
+

+ {content.message} +

+
+ + {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, t.userInput)} + +
+
+ ) + })} + {content.submittedAt && ( +
+ {new Date(content.submittedAt).toLocaleString()} +
+ )} +
+ )} +
+ ) +} + +// --- Editable form fields --- + +function FieldLabel({ title, description }: { title?: string; description?: string }) { + if (!title && !description) return null + return ( +
+ {title && {title}} + {description && ( + {description} + )} +
+ ) +} + +// --- PopoverSelect (portal-based, shared by SelectField / OneOfSelectField) --- + +function PopoverSelect({ + value, + placeholder, + options, + disabled, + onChange, +}: { + value: string | undefined + placeholder: string + options: Array<{ value: string; label: string }> + disabled: boolean + onChange: (val: string) => void +}) { + const [open, setOpen] = useState(false) + const [menuStyle, setMenuStyle] = useState({}) + const triggerRef = useRef(null) + const menuRef = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if ( + menuRef.current?.contains(e.target as Node) || + triggerRef.current?.contains(e.target as Node) + ) return + setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + // Close on scroll to avoid misalignment (the form is in a scrollable container) + useEffect(() => { + if (!open) return + const handler = () => setOpen(false) + window.addEventListener('scroll', handler, true) + return () => window.removeEventListener('scroll', handler, true) + }, [open]) + + const handleOpen = () => { + if (disabled) return + if (!open && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + const viewportHeight = window.innerHeight + const viewportWidth = window.innerWidth + const margin = 8 + const menuGap = 4 + const preferredMaxHeight = 220 + const minUsefulHeight = 88 + const estimatedMenuHeight = Math.min(preferredMaxHeight, options.length * 37 + 8) + const spaceBelow = viewportHeight - rect.bottom - margin - menuGap + const spaceAbove = rect.top - margin - menuGap + const openAbove = spaceBelow < Math.min(estimatedMenuHeight, 150) && spaceAbove > spaceBelow + const availableHeight = Math.max(minUsefulHeight, openAbove ? spaceAbove : spaceBelow) + const maxHeight = Math.min(preferredMaxHeight, availableHeight) + const left = Math.max(margin, Math.min(rect.left, viewportWidth - rect.width - margin)) + setMenuStyle({ + position: 'fixed', + top: openAbove ? rect.top - menuGap - maxHeight : rect.bottom + menuGap, + left, + width: rect.width, + maxHeight, + zIndex: 9999, + }) + } + setOpen((v) => !v) + } + + const selectOption = useCallback((opt: string) => { + onChange(opt) + setOpen(false) + }, [onChange]) + + const displayLabel = value + ? (options.find(o => o.value === value)?.label ?? value) + : placeholder + + const menu = open ? ( +
+ {options.map((opt) => { + const selected = value === opt.value + return ( +
selectOption(opt.value)} + className="flex items-center px-3 py-2 text-[14px] cursor-pointer transition-[background-color] duration-[60ms]" + style={{ + background: selected ? 'var(--c-bg-sub)' : 'transparent', + color: 'var(--c-text-primary)', + }} + onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = 'var(--c-bg-deep)' }} + onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent' }} + > + {opt.label} +
+ ) + })} +
+ ) : null + + return ( +
+ + {menu && createPortal(menu, document.body)} +
+ ) +} + +function SelectField({ + field, value, required, disabled, onChange, +}: { + field: { title?: string; description?: string; enum: string[]; enumNames?: string[] } + value: string | undefined + required: boolean + disabled: boolean + onChange: (val: string) => void +}) { + const { t } = useLocale() + const options = useMemo(() => + field.enum.map((v, i) => ({ value: v, label: field.enumNames?.[i] ?? v })), + [field.enum, field.enumNames], + ) + return ( +
+ + +
+ ) +} + +function OneOfSelectField({ + field, value, required, disabled, onChange, +}: { + field: { title?: string; description?: string; oneOf: Array<{ const: string; title: string }> } + value: string | undefined + required: boolean + disabled: boolean + onChange: (val: string) => void +}) { + const { t } = useLocale() + const options = useMemo(() => + field.oneOf.map(o => ({ value: o.const, label: o.title })), + [field.oneOf], + ) + return ( +
+ + +
+ ) +} + +function MultiSelectField({ + field, value, disabled, onChange, +}: { + field: import('../userInputTypes').ArrayEnumFieldSchema | import('../userInputTypes').ArrayAnyOfFieldSchema + value: string[] + disabled: boolean + onChange: (val: string[]) => void +}) { + const toggle = useCallback((opt: string) => { + onChange(value.includes(opt) ? value.filter(x => x !== opt) : [...value, opt]) + }, [value, onChange]) + + const options = isArrayEnumField(field) + ? field.items.enum.map(v => ({ value: v, label: v })) + : field.items.anyOf.map(o => ({ value: o.const, label: o.title })) + + return ( +
+ +
+ {options.map(opt => ( + + ))} +
+
+ ) +} + +function BooleanField({ + field, value, disabled, onChange, +}: { + field: { title?: string; description?: string } + value: boolean | undefined + disabled: boolean + onChange: (val: boolean) => void +}) { + const [hovered, setHovered] = useState(false) + return ( +
+ +
+ ) +} + +function TextInputField({ + fieldKey, field, value, disabled, onChange, +}: { + fieldKey: string + field: { title?: string; description?: string; maxLength?: number } + value: string + disabled: boolean + onChange: (val: string) => void +}) { + return ( +
+ + onChange(e.target.value)} + maxLength={field.maxLength} + disabled={disabled} + className="w-full rounded-lg px-3 py-2 text-[14px] font-light outline-none" + style={{ + background: 'var(--c-bg-deep)', + color: 'var(--c-text-primary)', + border: '0.5px solid var(--c-border-subtle)', + caretColor: 'var(--c-text-primary)', + }} + /> +
+ ) +} + +function NumberInputField({ + fieldKey, field, value, disabled, onChange, +}: { + fieldKey: string + field: { title?: string; description?: string; minimum?: number; maximum?: number; type: 'number' | 'integer' } + value: number | undefined + disabled: boolean + onChange: (val: number) => void +}) { + return ( +
+ + { + const v = field.type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value) + if (!isNaN(v)) onChange(v) + }} + min={field.minimum} + max={field.maximum} + step={field.type === 'integer' ? 1 : 'any'} + disabled={disabled} + className="w-full rounded-lg px-3 py-2 text-[14px] font-light outline-none" + style={{ + background: 'var(--c-bg-deep)', + color: 'var(--c-text-primary)', + border: '0.5px solid var(--c-border-subtle)', + caretColor: 'var(--c-text-primary)', + }} + /> +
+ ) +} + +function getDefaultValue(field: FieldSchema): FieldValue | undefined { + if ('default' in field && field.default !== undefined) { + return field.default as FieldValue + } + return undefined +} + +function EditableFormView({ + content, + disabled, + onSubmit, + onDismiss, +}: { + content: AgentAskUserFormContent + disabled: boolean + onSubmit: (answers: Record) => void + onDismiss: () => void +}) { + const { t } = useLocale() + const fields = useMemo(() => { + const order = content.schema._fieldOrder + const props = content.schema.properties as Record + if (order) { + return order + .filter(key => key in props) + .map(key => [key, props[key]] as [string, FieldSchema]) + } + return Object.entries(props) + }, [content.schema]) + + const requiredSet = useMemo(() => { + return new Set(content.schema.required ?? []) + }, [content.schema.required]) + + const [values, setValues] = useState>(() => { + const initial: Record = {} + for (const [key, field] of Object.entries(content.schema.properties as Record)) { + const def = getDefaultValue(field) + if (def !== undefined) initial[key] = def + } + return initial + }) + + const [submitting, setSubmitting] = useState(false) + + const setValue = useCallback((key: string, val: FieldValue) => { + setValues(prev => ({ ...prev, [key]: val })) + }, []) + + const allValid = useMemo(() => { + for (const key of requiredSet) { + const v = values[key] + if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return false + } + return true + }, [values, requiredSet]) + + const doSubmit = useCallback(() => { + if (!allValid || submitting || disabled) return + setSubmitting(true) + onSubmit(values) + }, [allValid, submitting, disabled, onSubmit, values]) + + const handleDismiss = useCallback(() => { + if (submitting || disabled) return + onDismiss() + }, [submitting, disabled, onDismiss]) + + return ( +
+

+ {content.message} +

+ +
+ {fields.map(([key, field]) => { + if (isEnumField(field)) { + return ( + setValue(key, val)} + /> + ) + } + if (isOneOfField(field)) { + return ( + setValue(key, val)} + /> + ) + } + if (isArrayEnumField(field) || isArrayAnyOfField(field)) { + return ( + setValue(key, val)} + /> + ) + } + if (isBooleanField(field)) { + return ( + setValue(key, val)} + /> + ) + } + if (isNumberField(field)) { + return ( + setValue(key, val)} + /> + ) + } + if (isTextField(field)) { + return ( + setValue(key, val)} + /> + ) + } + return null + })} +
+ +
+ + +
+
+ ) +} + +export default function AskUserFormMessageCard({ content, activeRunId, onSubmit, onDismiss }: Props) { + const isPending = content.status === 'pending' + const isEditable = isPending && activeRunId === content.runId + + const handleSubmit = useCallback(async (answers: Record) => { + await onSubmit(content.requestId, answers) + }, [onSubmit, content.requestId]) + + const handleDismiss = useCallback(async () => { + await onDismiss(content.requestId) + }, [onDismiss, content.requestId]) + + if (isEditable) { + return ( + + ) + } + + return +} diff --git a/src/apps/web/src/components/ChatView.tsx b/src/apps/web/src/components/ChatView.tsx index 7bcbc8919..aed988d20 100644 --- a/src/apps/web/src/components/ChatView.tsx +++ b/src/apps/web/src/components/ChatView.tsx @@ -18,6 +18,7 @@ import { useTypewriter } from '../hooks/useTypewriter' import { ArtifactStreamBlock, type StreamingArtifactEntry } from './ArtifactStreamBlock' import { WidgetBlock } from './WidgetBlock' import UserInputCard from './UserInputCard' +import AskUserFormMessageCard from './AskUserFormMessageCard' import { resolveMessageSourcesForRender } from './chatSourceResolver' import { RunErrorNotice, type AppError } from './ErrorCallout' import { ShareModal } from './ShareModal' @@ -1001,6 +1002,8 @@ export const ChatView = memo(function ChatView() { setAwaitingInput, pendingUserInput, setPendingUserInput, + pendingFormInput, + setPendingFormInput, checkInDraft, setCheckInDraft, checkInSubmitting, @@ -1244,6 +1247,8 @@ export const ChatView = memo(function ChatView() { handleCheckInSubmit, handleUserInputSubmit, handleUserInputDismiss, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleAsrError, handleArtifactAction, } = useChatActions({ scrollToBottom: activateAnchor, onSelectForkAnchor: selectForkAnchor }) @@ -1783,6 +1788,7 @@ export const ChatView = memo(function ChatView() { setCancelSubmitting(false) setAwaitingInput(false) setPendingUserInput(null) + setPendingFormInput(null) setCheckInDraft('') setQueuedPrompts([]) setEditingQueuedPromptId(null) @@ -3609,6 +3615,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 +3638,8 @@ export const ChatView = memo(function ChatView() { currentRunCopHeaderOverride, displayedMessages, handleArtifactAction, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleEditMessage, handleFork, handleRetryUserMessage, @@ -3743,7 +3753,23 @@ export const ChatView = memo(function ChatView() { )} )} - {pendingUserInput ? ( + {pendingFormInput ? ( + + + + ) : pendingUserInput ? ( 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,37 @@ export const MessageList = memo(forwardRef( ) if (hideTerminalRunMessage) return null + // Active ask_user forms are rendered exclusively in the bottom input area. + // Keeping the message-row prompt visible here causes duplicate prompt text. + if ( + msg.role === 'assistant' && + msg.contentJson && + 'kind' in msg.contentJson && + msg.contentJson.kind === 'ask_user_form' && + handleAskUserFormSubmit && + handleAskUserFormDismiss + ) { + const formContent = msg.contentJson as AgentAskUserFormContent + const isActivePendingForm = formContent.status === 'pending' && run.activeRunId === formContent.runId + if (isActivePendingForm) return null + 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 +643,8 @@ export const MessageList = memo(forwardRef( createShareForMessage, currentRunCopHeaderOverride, handleArtifactAction, + handleAskUserFormSubmit, + handleAskUserFormDismiss, handleFork, hasCurrentRunHandoffUi, isSearchThread, @@ -625,6 +663,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/run-lifecycle.tsx b/src/apps/web/src/contexts/run-lifecycle.tsx index d56da5328..8b450a34f 100644 --- a/src/apps/web/src/contexts/run-lifecycle.tsx +++ b/src/apps/web/src/contexts/run-lifecycle.tsx @@ -10,7 +10,7 @@ import { } from 'react' import { useAgentStream, type UseAgentStreamResult } from '../hooks/useAgentStream' import { type AppError } from '@arkloop/shared' -import { useAgentClient } from '../agent-ui' +import { useAgentClient, type AgentAskUserFormContent } from '../agent-ui' import type { UserInputRequest } from '../userInputTypes' import { useChatSession } from './chat-session' import type { QueuedPrompt } from '../queuedPrompts' @@ -33,6 +33,7 @@ interface RunLifecycleContextValue { queuedPrompts: QueuedPrompt[] awaitingInput: boolean pendingUserInput: UserInputRequest | null + pendingFormInput: AgentAskUserFormContent | null checkInDraft: string checkInSubmitting: boolean contextCompactBar: ContextCompactBarState | null @@ -56,6 +57,7 @@ interface RunLifecycleContextValue { setQueuedPrompts: React.Dispatch> setAwaitingInput: (v: boolean) => void setPendingUserInput: (v: UserInputRequest | null) => void + setPendingFormInput: (v: AgentAskUserFormContent | null) => void setCheckInDraft: (v: string) => void setCheckInSubmitting: (v: boolean) => void setContextCompactBar: (v: ContextCompactBarState | null) => void @@ -91,6 +93,7 @@ export function RunLifecycleProvider({ children }: { children: ReactNode }) { const [queuedPrompts, setQueuedPrompts] = useState([]) const [awaitingInput, setAwaitingInput] = useState(false) const [pendingUserInput, setPendingUserInput] = useState(null) + const [pendingFormInput, setPendingFormInput] = useState(null) const [checkInDraft, setCheckInDraft] = useState('') const [checkInSubmitting, setCheckInSubmitting] = useState(false) const [contextCompactBar, setContextCompactBar] = useState(null) @@ -169,6 +172,7 @@ export function RunLifecycleProvider({ children }: { children: ReactNode }) { setInjectionBlocked(null) setAwaitingInput(false) setPendingUserInput(null) + setPendingFormInput(null) setCheckInDraft('') setCheckInSubmitting(false) setContextCompactBar(null) @@ -273,6 +277,7 @@ export function RunLifecycleProvider({ children }: { children: ReactNode }) { setInjectionBlocked(null) setAwaitingInput(false) setPendingUserInput(null) + setPendingFormInput(null) setCheckInDraft('') setCheckInSubmitting(false) setQueuedPrompts([]) @@ -297,6 +302,7 @@ export function RunLifecycleProvider({ children }: { children: ReactNode }) { queuedPrompts, awaitingInput, pendingUserInput, + pendingFormInput, checkInDraft, checkInSubmitting, contextCompactBar, @@ -317,6 +323,7 @@ export function RunLifecycleProvider({ children }: { children: ReactNode }) { setQueuedPrompts, setAwaitingInput, setPendingUserInput, + setPendingFormInput, setCheckInDraft, setCheckInSubmitting, setContextCompactBar, @@ -344,6 +351,7 @@ export function RunLifecycleProvider({ children }: { children: ReactNode }) { queuedPrompts, awaitingInput, pendingUserInput, + pendingFormInput, checkInDraft, checkInSubmitting, contextCompactBar, 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..e9190d6f4 100644 --- a/src/apps/web/src/hooks/useChatActions.ts +++ b/src/apps/web/src/hooks/useChatActions.ts @@ -72,6 +72,7 @@ export function useChatActions({ scrollToBottom, onSelectForkAnchor }: UseChatAc setAwaitingInput, pendingUserInput, setPendingUserInput, + setPendingFormInput, checkInDraft, setCheckInDraft, checkInSubmitting, @@ -234,8 +235,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 +511,38 @@ 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) + setPendingFormInput(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, setPendingFormInput]) + + const handleAskUserFormDismiss = useCallback(async (_requestId: string) => { + if (!activeRunId) return + setError(null) + setInjectionBlocked(null) + setPendingFormInput(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, setPendingFormInput]) + const handleAsrError = useCallback((err: unknown) => { if (isApiError(err) && err.status === 401) { onLoggedOut() @@ -563,6 +598,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..9d8423b2d 100644 --- a/src/apps/web/src/hooks/useThreadSseEffect.ts +++ b/src/apps/web/src/hooks/useThreadSseEffect.ts @@ -89,6 +89,7 @@ export function useThreadSseEffect({ injectionBlockedRunIdRef, setAwaitingInput, setPendingUserInput, + setPendingFormInput, setCheckInDraft, contextCompactBar: _contextCompactBar, setContextCompactBar, @@ -294,6 +295,7 @@ export function useThreadSseEffect({ pendingSearchStepsRef.current = null setAwaitingInput(false) setPendingUserInput(null) + setPendingFormInput(null) setCheckInDraft('') if (threadId) onRunEnded(threadId) } @@ -740,16 +742,45 @@ export function useThreadSseEffect({ } if (event.type === 'input-request') { - // SSE 重连时会重放历史事件,只有 run 实际继续执行的事件才能证明 input 已被回答 + // SSE 重连时会重放历史事件,只要有任何后续事件就说明 input 已被回答 + // input-request 是阻塞点,在它之后不可能出现其他事件除非用户已响应 const hasRunContinued = sse.events.some( - (e) => e.streamId === event.streamId && e.order > event.order - && (e.type === 'tool-result' || isTerminalAgentEventType(e.type)), + (e) => e.streamId === event.streamId && e.order > event.order, ) if (hasRunContinued) continue 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 render in the input area during filling; + // they appear in the chat flow only after submission (via message sync). + if (displayMode === 'form') { + const requestId = (data?.requestId as string) ?? '' + const runId = event.streamId + const schemaData = data?.requestedSchema as Record | undefined + if (requestId && runId && schemaData) { + setPendingFormInput({ + kind: 'ask_user_form', + displayMode: 'form', + requestId, + runId, + message: message ?? '', + schema: { + properties: (schemaData.properties as Record) ?? {}, + required: schemaData.required as string[] | undefined, + _fieldOrder: schemaData._fieldOrder as string[] | undefined, + }, + status: 'pending', + answers: null, + submittedAt: null, + }) + } + + continue + } + if (message && schema && schema.properties && Object.keys(schema.properties).length > 0) { const safeSchema: RequestedSchema = { ...schema, @@ -832,6 +863,7 @@ export function useThreadSseEffect({ } setAwaitingInput(false) setPendingUserInput(null) + setPendingFormInput(null) setCheckInDraft('') if (threadId) onRunEnded(threadId) refreshCredits() @@ -1086,6 +1118,7 @@ export function useThreadSseEffect({ setPendingThinking(false) setAwaitingInput(false) setPendingUserInput(null) + setPendingFormInput(null) setCheckInDraft('') if (threadId) onRunEnded(threadId) refreshCredits() diff --git a/src/apps/web/src/locales/en.ts b/src/apps/web/src/locales/en.ts index 85e156e75..f4dfc33d9 100644 --- a/src/apps/web/src/locales/en.ts +++ b/src/apps/web/src/locales/en.ts @@ -566,6 +566,12 @@ export const en: LocaleStrings = { submitting: "Submitting...", next: "Next", back: "Back", + yes: "Yes", + no: "No", + hideAnswers: "Hide answers", + showAnswers: (count: number) => `Show ${count} answer${count > 1 ? 's' : ''}`, + selectPlaceholder: "Select...", + optionalPlaceholder: "Optional", }, // document panel documentPanel: { diff --git a/src/apps/web/src/locales/index.ts b/src/apps/web/src/locales/index.ts index ac6f1dd9e..1c3ef01f2 100644 --- a/src/apps/web/src/locales/index.ts +++ b/src/apps/web/src/locales/index.ts @@ -555,6 +555,12 @@ export interface LocaleStrings { submitting: string next: string back: string + yes: string + no: string + hideAnswers: string + showAnswers: (count: number) => string + selectPlaceholder: string + optionalPlaceholder: string } // document panel documentPanel: { diff --git a/src/apps/web/src/locales/zh.ts b/src/apps/web/src/locales/zh.ts index 0f1a901cf..988c74d71 100644 --- a/src/apps/web/src/locales/zh.ts +++ b/src/apps/web/src/locales/zh.ts @@ -561,6 +561,12 @@ export const zh: LocaleStrings = { submitting: "提交中...", next: "下一步", back: "上一步", + yes: "是", + no: "否", + hideAnswers: "隐藏回答", + showAnswers: (count: number) => `显示 ${count} 个回答`, + selectPlaceholder: "请选择...", + optionalPlaceholder: "可选", }, // document panel documentPanel: { 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