Skip to content
Merged
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
9 changes: 9 additions & 0 deletions docs/ui-interaction-and-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,12 @@ For motion changes:

Motion should be judged in the running UI. A class name or screenshot alone
cannot prove timing, continuity, or pointer feedback.

Web question cards keep the existing composer status placement. Text-capable
questions show a labeled shared Textarea and an explicit Send answer button;
offered options stay available above it. Secret questions use a masked field.
The layout stacks vertically at narrow widths and does not steal focus.
Permission cards remain option-only. A failed submission retains the draft,
while a new request ID mounts a fresh card so answers do not leak between
questions. This is owned by the shared ConversationRequestCard, not a
runtime-specific presenter.
11 changes: 9 additions & 2 deletions docs/web-conversation-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ approval or cannot reopen an exact recorded conversation.
| `claude-stream-json` | `claude` | `-p --input-format stream-json --output-format stream-json --include-partial-messages --permission-prompt-tool stdio` | `control_request` `can_use_tool`; answered with allow/deny | `--session-id <uuid>` chosen by the adapter |
| `codex-app-server` | `codex` | `codex app-server --listen stdio://` with MCP registration, `approvalPolicy: on-request`, `sandbox: workspace-write` | `item/commandExecution/requestApproval`, `item/fileChange/requestApproval` (answered with a `decision` enum), `item/permissions/requestApproval` (answered with the granted `permissions` profile + `scope`), `item/tool/requestUserInput` | `thread/start`; resume via `thread/resume` |

If ACP does not advertise `loadSession`, opening an existing Session fails with
terminal guidance and preserves its native ID. Never replace its transcript
with `session/new`; only a separate fresh Session may allocate a new ID.

The Web surface never resumes "last": it reopens the exact recorded native id
or starts a fresh conversation the transport reports back, so the Session's
`resumeId` binds to one native transcript exactly as PTY discovery does.
Expand Down Expand Up @@ -87,8 +91,11 @@ components, labels) use "Web". User-facing copy says "Web", never "WebPi".
running headless turn, disposes a PTY on the same record, starts the host.
- `GET /web?revision=` — snapshot or `{ unchanged: true }`.
- `POST /web/prompt`, `POST /web/abort` — turn control.
- `POST /web/respond { requestId, optionId }` — answers one request; the
transport validates the option id and fails with `web_respond_failed`.
- `POST /web/respond { requestId, optionId, text? }` — answers one request; the
transport validates the option id and fails with `web_respond_failed`. For a
question with `allowText`, send `optionId: ""` and nonblank `text` to answer
freely. Permission requests reject text answers. Secret questions use a masked
field. Cancelling a turn clears the entire pending question sequence.

Switching a running Web Session to the TUI stops the Web process first; the
reverse disposes the PTY. Exactly one process may own a Session record.
Expand Down
7 changes: 6 additions & 1 deletion src/webui/routes/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1736,9 +1736,14 @@ describe('Web surface routes', () => {
const { app, web } = buildWeb();
const result = await post(app, `/ws-1/sessions/${TOKEN}/web/respond`, { requestId: 'acp-7', optionId: 'allow_once' });
expect(result.status).toBe(200);
expect(web.respond).toHaveBeenCalledWith(TOKEN, 'acp-7', 'allow_once');
expect(web.respond).toHaveBeenCalledWith(TOKEN, 'acp-7', 'allow_once', undefined);
const bad = await post(app, `/ws-1/sessions/${TOKEN}/web/respond`, { requestId: 'acp-7' });
expect(bad.status).toBe(400);
const answer = await post(app, `/ws-1/sessions/${TOKEN}/web/respond`, { requestId: 'q1', optionId: '', text: 'Alice' });
expect(answer.status).toBe(200);
expect(web.respond).toHaveBeenCalledWith(TOKEN, 'q1', '', 'Alice');
const invalid = await post(app, `/ws-1/sessions/${TOKEN}/web/respond`, { requestId: 'q1', optionId: '', text: 123 });
expect(invalid.status).toBe(400);
});

it('returns a tiny unchanged response when the browser already has the revision', async () => {
Expand Down
8 changes: 5 additions & 3 deletions src/webui/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2581,19 +2581,21 @@ export function createWorkspaceRoutes(
});

// Answer a runtime permission/question request with one of the options the
// runtime itself offered. The transport validates the option id.
// runtime itself offered, or free text for a question. The host and transport
// validate the answer against the pending request.
app.post('/:id/sessions/:sid/web/respond', async (c) => {
const ctx = webSessionContext(c);
if (!ctx) return c.json({ error: 'not_found' }, 404);
const body = await safeJson(c).catch(() => null);
const fields = body && typeof body === 'object' ? body as Record<string, unknown> : {};
const requestId = fields['requestId'];
const optionId = fields['optionId'];
if (typeof requestId !== 'string' || !requestId || typeof optionId !== 'string') {
const text = fields['text'];
if (typeof requestId !== 'string' || !requestId || typeof optionId !== 'string' || (text !== undefined && typeof text !== 'string')) {
return c.json({ error: 'bad_request', message: 'requestId and optionId are required' }, 400);
}
try {
const snapshot = await svc.web.respond(ctx.token, requestId, optionId);
const snapshot = await svc.web.respond(ctx.token, requestId, optionId, text as string | undefined);
await svc.sessionRegistry.update(ctx.id, ctx.token, { lastActiveAt: new Date().toISOString() });
return c.json({ ok: true, snapshot });
} catch (err) {
Expand Down
69 changes: 69 additions & 0 deletions src/workspaces/web-session-host.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,22 @@ describe('WebSessionHost with the acp transport', () => {
expect(process.received.find((c) => c['id'] === 'srv-1')).toMatchObject({ result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } })
})

it('refuses unsupported restore without creating or rebinding a native session', async () => {
const process = acpProcess({ loadSession: false })
const bind = vi.fn()
const host = new WebSessionHost(logger, { onNativeSessionId: bind }, () => process as never)
await expect(host.start({ ...acpInput, nativeSessionId: 'ses_old' })).rejects.toThrow(/terminal/)
expect(process.received.some((frame) => frame['method'] === 'session/new')).toBe(false)
expect(bind).not.toHaveBeenCalled()
expect(host.has('record-1')).toBe(false)
})

it('still creates fresh sessions when ACP cannot load history', async () => {
const host = new WebSessionHost(logger, {}, () => acpProcess({ loadSession: false }) as never)
expect((await host.start(acpInput)).nativeSessionId).toBe('ses_new')
await host.stopAll()
})

it('reloads a known session and replays its history', async () => {
const host = new WebSessionHost(logger, {}, () => acpProcess() as never)
const started = await host.start({ ...acpInput, nativeSessionId: 'ses_old' })
Expand Down Expand Up @@ -554,3 +570,56 @@ describe('WebSessionHost with the codex-app-server transport', () => {
expect(texts(snapshot).at(-1)).toBe('notice:Turn interrupted.')
})
})


describe('Codex question answers', () => {
async function setup() {
const process = codexProcess()
const host = new WebSessionHost(logger, {}, () => process as never)
await host.start(input({ agent: 'codex', wire: 'codex-app-server', command: ['codex', 'app-server'] }))
process.line({ id: 'questions', method: 'item/tool/requestUserInput', params: { questions: [
{ id: 'name', header: 'Project name', question: 'What name?', options: null },
{ id: 'style', header: 'Style', question: 'Which style?', options: [{ label: 'Simple' }], isOther: true },
] } })
await settle()
return { process, host }
}

it('round-trips free text and then a selected option', async () => {
const { process, host } = await setup()
const first = host.get('record-1')!.requests[0]!
expect(first).toMatchObject({ allowText: true, options: [] })
await expect(host.respond('record-1', first.id, '', ' ')).rejects.toThrow(/text/)
expect(host.get('record-1')!.requests).toHaveLength(1)
await host.respond('record-1', first.id, '', 'Alice research')
await settle()
await host.respond('record-1', host.get('record-1')!.requests[0]!.id, 'Simple')
await settle()
expect(process.received.find((frame) => frame['id'] === 'questions')).toMatchObject({ result: { answers: {
name: { answers: ['Alice research'] }, style: { answers: ['Simple'] },
} } })
await host.stopAll()
})

it('does not enqueue later questions when the turn is cancelled', async () => {
const { process, host } = await setup()
process.line({ method: 'turn/completed', params: { turn: { id: 't', status: 'interrupted' } } })
await settle()
expect(host.get('record-1')!.requests).toEqual([])
expect(host.get('record-1')!.phase).toBe('idle')
expect(process.received.find((frame) => frame['id'] === 'questions')).toMatchObject({ result: { answers: {} } })
await host.stopAll()
})

it('rejects text answers to permission requests without consuming the request', async () => {
const process = codexProcess({ permissions: true })
const host = new WebSessionHost(logger, {}, () => process as never)
await host.start(input({ agent: 'codex', wire: 'codex-app-server', command: ['codex', 'app-server'] }))
await host.prompt('record-1', 'test')
await settle()
const request = host.get('record-1')!.requests[0]!
await expect(host.respond('record-1', request.id, 'grant', 'yes')).rejects.toThrow(/text/)
expect(host.get('record-1')!.requests).toHaveLength(1)
await host.stopAll()
})
})
12 changes: 8 additions & 4 deletions src/workspaces/web-session-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ export class WebSessionHost {
return session.snapshot()
}

async respond(recordId: string, requestId: string, optionId: string): Promise<WebSessionSnapshot> {
async respond(recordId: string, requestId: string, optionId: string, text?: string): Promise<WebSessionSnapshot> {
const session = this.require(recordId)
await session.respond(requestId, optionId)
await session.respond(requestId, optionId, text)
return session.snapshot()
}

Expand Down Expand Up @@ -231,9 +231,13 @@ class LiveWebSession {
return this.transport.abort()
}

respond(requestId: string, optionId: string): Promise<void> {
respond(requestId: string, optionId: string, text?: string): Promise<void> {
this.assertLive()
return this.transport.respond(requestId, optionId)
const request = this.state.requests.find((entry) => entry.id === requestId)
if (text !== undefined && (request?.kind !== 'question' || !request.allowText || optionId !== '' || !text.trim())) {
return Promise.reject(new Error('This request does not accept this text answer'))
}
return this.transport.respond(requestId, optionId, text)
}

async stop(reason: string): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion src/workspaces/web-session/acp-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class AcpTransport implements WebSessionTransport {
this.builder.endTurn()
} else {
if (this.sessionId) {
this.builder.notice(`${this.ctx.input.agent} cannot reload session ${this.sessionId} over ACP; a new native session was started.`)
throw new Error(`${this.ctx.input.agent} cannot reopen this conversation over ACP. Open the existing Session in the terminal, or create a separate new Session.`)
}
const created = await this.peer.request('session/new', params)
const id = isJsonObject(created) ? stringOrNull(created['sessionId']) : null
Expand Down
16 changes: 12 additions & 4 deletions src/workspaces/web-session/codex-app-server-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class CodexAppServerTransport implements WebSessionTransport {
private threadId: string | null
private turnId: string | null = null
private requestSeq = 0
private cancellationEpoch = 0

constructor(private readonly ctx: WebTransportContext) {
this.threadId = ctx.input.nativeSessionId ?? null
Expand Down Expand Up @@ -103,16 +104,18 @@ export class CodexAppServerTransport implements WebSessionTransport {
await this.peer.request('turn/interrupt', { threadId: this.threadId, turnId: this.turnId })
}

async respond(requestId: string, optionId: string): Promise<void> {
async respond(requestId: string, optionId: string, text?: string): Promise<void> {
const request = this.ctx.state.requests.find((r) => r.id === requestId)
const pending = this.approvals.get(requestId)
if (!request || !pending) throw new Error(`no pending request ${requestId}`)
if (!request.options.some((option) => option.id === optionId)) {
const textAnswer = text !== undefined && request.kind === 'question' && request.allowText && optionId === '' && text.trim()
if (text !== undefined && !textAnswer) throw new Error('Invalid text answer')
if (!textAnswer && !request.options.some((option) => option.id === optionId)) {
throw new Error(`option ${optionId} is not offered by request ${requestId}`)
}
this.approvals.delete(requestId)
this.ctx.state.removeRequest(requestId)
pending.answer(pending.respondWith(optionId))
pending.answer(pending.respondWith(textAnswer ? text! : optionId))
}

dispose(): void {
Expand Down Expand Up @@ -359,6 +362,7 @@ export class CodexAppServerTransport implements WebSessionTransport {
private async userInput(params: JsonObject): Promise<unknown> {
const questions = Array.isArray(params['questions']) ? params['questions'].filter(isJsonObject) : []
const answers: Record<string, { answers: string[] }> = {}
const epoch = this.cancellationEpoch
for (const question of questions) {
const questionId = stringOrNull(question['id']) ?? `q${Object.keys(answers).length}`
const options = Array.isArray(question['options'])
Expand All @@ -372,8 +376,11 @@ export class CodexAppServerTransport implements WebSessionTransport {
kind: 'question',
title: stringOrNull(question['header']) ?? 'Codex has a question',
description: stringOrNull(question['question']) ?? '',
options: options.length > 0 ? options : [{ id: '', label: 'Continue without an answer', tone: 'neutral' }],
options,
allowText: true,
secret: question['isSecret'] === true,
}, (optionId) => optionId, '')
if (epoch !== this.cancellationEpoch) return { answers: {} }
answers[questionId] = { answers: typeof choice === 'string' && choice ? [choice] : [] }
}
return { answers }
Expand All @@ -393,6 +400,7 @@ export class CodexAppServerTransport implements WebSessionTransport {
}

private cancelApprovals(): void {
this.cancellationEpoch += 1
for (const [id, pending] of this.approvals) {
this.approvals.delete(id)
this.ctx.state.removeRequest(id)
Expand Down
4 changes: 3 additions & 1 deletion src/workspaces/web-session/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ export interface WebRequestOption {
/**
* A question the runtime cannot answer on its own: a tool permission, a file
* change approval, or a free-form user question. The browser presents the
* options verbatim and answers with one `optionId`.
* options verbatim and answers with one `optionId`, or text when allowed.
*/
export interface WebPermissionRequest {
readonly id: string
readonly kind: 'permission' | 'question'
readonly allowText?: boolean
readonly secret?: boolean
readonly title: string
readonly description?: string
readonly tool?: { readonly name: string; readonly input: unknown }
Expand Down
2 changes: 1 addition & 1 deletion src/workspaces/web-session/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export interface WebSessionTransport {
prompt(message: string): Promise<void>
abort(): Promise<void>
/** Answer one outstanding request with the chosen option id. */
respond(requestId: string, optionId: string): Promise<void>
respond(requestId: string, optionId: string, text?: string): Promise<void>
/** Polite shutdown before the host closes stdin and signals the process. */
dispose?(): void
}
Expand Down
29 changes: 29 additions & 0 deletions ui/src/components/conversation/ConversationRequestCard.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { ConversationRequestCard } from './ConversationRequestCard'

afterEach(cleanup)
const question = { id: 'q1', kind: 'question' as const, title: 'Project name', allowText: true, options: [] }

it('retains the draft after an error and permits a retry', async () => {
const respond = vi.fn().mockRejectedValueOnce(new Error('Connection lost')).mockResolvedValue(undefined)
render(<ConversationRequestCard request={question} queued={0} respond={respond} />)
const field = screen.getByLabelText('Your answer') as HTMLTextAreaElement
fireEvent.change(field, { target: { value: 'Alice research' } })
fireEvent.click(screen.getByRole('button', { name: 'Send answer' }))
await screen.findByRole('alert')
expect(field.value).toBe('Alice research')
fireEvent.click(screen.getByRole('button', { name: 'Send answer' }))
await waitFor(() => expect(respond).toHaveBeenCalledTimes(2))
expect(respond).toHaveBeenLastCalledWith('q1', '', 'Alice research')
})

it('masks secret input and does not expose text fields on permissions', () => {
const { unmount } = render(<ConversationRequestCard request={{ ...question, secret: true }} queued={0} respond={vi.fn()} />)
expect(screen.getByLabelText('Your answer').getAttribute('type')).toBe('password')
unmount()
render(<ConversationRequestCard request={{ ...question, kind: 'permission', options: [{ id: 'allow', label: 'Allow', tone: 'allow' }] }} queued={0} respond={vi.fn()} />)
expect(screen.queryByLabelText('Your answer')).toBeNull()
expect(screen.getByRole('button', { name: 'Allow' })).toBeTruthy()
})
Loading