feat(gui): add ask-user question composer

This commit is contained in:
Yichen Jiang
2026-07-22 23:39:50 +08:00
parent b51d2b3d67
commit 03889cee1a
58 changed files with 1905 additions and 115 deletions

View File

@@ -4,7 +4,7 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { MuxFrame, RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
@@ -121,11 +121,14 @@ export interface RunningToolCall {
callView: ToolCallView | null
}
/** Approval/question placeholder cards (visible, not answerable;
* rpcId = the requested frame's envelope id, the future respond backfill key). */
/** Approval/question pending state; rpcId is the requested frame's response-backfill key. */
export type PendingInteraction =
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
| {
kind: 'question'
rpcId: RpcId
questions: readonly Extract<MuxFrame, { type: 'question/requested' }>['questions'][number][]
}
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {

View File

@@ -5,7 +5,10 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import type {
HistoryEntry, IApiClient, MuxFrame, QuestionResponsePayload, RpcError, RpcId, RpcReceipt, RpcResult,
SessionId, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -120,6 +123,34 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Answer one host-owned question wait; pending clears only on the authoritative resolved frame.
* @param rpcId - Stable id from the requested frame.
* @param answer - Complete structured answer batch.
* @returns Carrier receipt; rejection leaves pending state unchanged.
*/
answerQuestion(rpcId: RpcId, answer: QuestionResponsePayload['answer']): Promise<RpcReceipt> {
return this.api.respond({
type: 'client-response', rpcId,
result: { ok: true, value: { sessionId: this.sessionId, answer } },
})
}
/**
* Cancel one host-owned question wait without encoding closure as skipped answers.
* @param rpcId - Stable id from the requested frame.
* @returns Carrier receipt; rejection leaves pending state unchanged.
*/
cancelQuestion(rpcId: RpcId): Promise<RpcReceipt> {
return this.api.respond({
type: 'client-response', rpcId,
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -54,6 +54,7 @@ export class FakeApiClient implements IApiClient {
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -93,8 +94,8 @@ export class FakeApiClient implements IApiClient {
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

View File

@@ -251,6 +251,30 @@ describe('pending interactions', () => {
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
it('backfills the requested rpcId for structured answers and explicit cancellation', async () => {
const { api, session } = makeSession()
await session.answerQuestion('rq-answer' as never, {
answers: [{ id: 'mode', selected: ['Fast'] }],
})
await session.cancelQuestion('rq-cancel' as never)
expect(api.callsOf('respond')).toEqual([
{
type: 'client-response', rpcId: 'rq-answer',
result: {
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
},
},
{
type: 'client-response', rpcId: 'rq-cancel',
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
},
])
})
})
describe('remaining branches', () => {