Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,061 changes: 1,061 additions & 0 deletions docs/superpowers/plans/2026-05-25-ask-user-form-chat.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

此文件不应提交

Large diffs are not rendered by default.

495 changes: 495 additions & 0 deletions docs/superpowers/specs/2026-05-25-ask-user-form-chat-design.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

此文件不应提交

Large diffs are not rendered by default.

231 changes: 231 additions & 0 deletions src/apps/web/src/__tests__/messageList.askUserForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="message-bubble">{contentOverride ?? message.content}</div>
),
}))

vi.mock('../components/AskUserFormMessageCard', () => ({
default: ({ content }: { content: { message: string } }) => (
<div data-testid="ask-user-form-card">{content.message}</div>
),
}))

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 }) => <div>{content}</div>,
}))

vi.mock('../components/WorkGroup', () => ({
WorkGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))

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<string>() }),
}))

vi.mock('../contexts/LocaleContext', () => ({
useLocale: () => ({
t: {
incognitoForkDivider: 'fork',
},
}),
}))

vi.mock('../lib/chat-helpers', () => ({
turnHasCopThinkingItems: () => false,
widgetToolCallIdsPlacedInTurn: () => new Set<string>(),
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<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useLocation: () => ({ state: null }),
}
})

describe('MessageList ask_user_form', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>

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(
<MessageList
ref={null}
lastTurnRef={createRef<HTMLDivElement>()}
lastUserPromptRef={createRef<HTMLDivElement>()}
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('请补充项目地址')
})
})
50 changes: 48 additions & 2 deletions src/apps/web/src/agent-ui/arkloop-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
retryMessage,
type MessageContent,
type MessageContentPart,
type AskUserFormContent,
type MessageResponse,
type RunEvent,
} from '../api'
Expand All @@ -21,6 +22,7 @@ import type {
AgentMessageAttachmentRef,
AgentMessageContent,
AgentMessageContentPart,
AgentAskUserFormContent,
AgentRun,
AgentOpenEventStreamOptions,
AgentUIEvent,
Expand Down Expand Up @@ -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<string, unknown>,
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) }
}

Expand Down
23 changes: 21 additions & 2 deletions src/apps/web/src/agent-ui/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
required?: string[]
_fieldOrder?: string[]
displayMode?: string
}
status: 'pending' | 'submitted' | 'dismissed' | 'expired'
answers: Record<string, unknown> | null
submittedAt: string | null
}

export type AgentMessageContent =
| { parts: AgentMessageContentPart[] }
| AgentAskUserFormContent

export type AgentProviderMetadata = Record<string, unknown>
export type AgentUIDataTypes = Record<string, unknown>

Expand Down Expand Up @@ -316,6 +334,7 @@ export type AgentInputRequestData = {
requestId?: string
message?: string
requestedSchema?: unknown
display_mode?: string
}

export type AgentSecurityBlockData = {
Expand Down
1 change: 1 addition & 0 deletions src/apps/web/src/agent-ui/event-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') } : {}),
}
}

Expand Down
1 change: 1 addition & 0 deletions src/apps/web/src/agent-ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type {
AgentAskUserFormContent,
AgentBackendAdapter,
AgentChatRequestOptions,
AgentClient,
Expand Down
22 changes: 20 additions & 2 deletions src/apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
required?: string[]
_fieldOrder?: string[]
display_mode?: string
}
status: 'pending' | 'submitted' | 'dismissed' | 'expired'
answers: Record<string, unknown> | null
submitted_at: string | null
}

export type MessageContent =
| { parts: MessageContentPart[] }
| AskUserFormContent

export type CreateMessageRequest = {
content?: string
content_json?: MessageContent
Expand Down
Loading
Loading