From 022abd04a9d9e19d939b534e2358a62734b47d00 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 14 Jul 2026 02:10:12 +0800 Subject: [PATCH 1/6] refactor(runtime): share turn-scoped await registry --- .../turn-scoped-await-registry.test.ts | 31 ++++++++ packages/runtime/src/permission-engine.ts | 61 ++++++--------- .../runtime/src/turn-scoped-await-registry.ts | 75 +++++++++++++++++++ 3 files changed, 128 insertions(+), 39 deletions(-) create mode 100644 packages/runtime/src/__tests__/turn-scoped-await-registry.test.ts create mode 100644 packages/runtime/src/turn-scoped-await-registry.ts diff --git a/packages/runtime/src/__tests__/turn-scoped-await-registry.test.ts b/packages/runtime/src/__tests__/turn-scoped-await-registry.test.ts new file mode 100644 index 0000000000..4a0201bb16 --- /dev/null +++ b/packages/runtime/src/__tests__/turn-scoped-await-registry.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { TurnScopedAwaitRegistry } from '../turn-scoped-await-registry.js'; + +describe('TurnScopedAwaitRegistry', () => { + test('settles one request and ignores a late response', async () => { + const registry = new TurnScopedAwaitRegistry(); + registry.beginTurn('turn-1'); + + const parked = registry.park('turn-1', 'request-1', { toolUseId: 'tool-1' }); + + assert.deepEqual(registry.resolve('turn-1', 'request-1', 'answer'), { toolUseId: 'tool-1' }); + assert.equal(await parked, 'answer'); + assert.equal(registry.resolve('turn-1', 'request-1', 'late'), null); + assert.equal(registry.pendingCount('turn-1'), 0); + }); + + test('ending a turn rejects every request and drops the turn', async () => { + const registry = new TurnScopedAwaitRegistry(); + registry.beginTurn('turn-1'); + const first = registry.park('turn-1', 'request-1', undefined); + const second = registry.park('turn-1', 'request-2', undefined); + + registry.endTurn('turn-1', (requestId) => new Error(`aborted ${requestId}`)); + + await assert.rejects(first, /aborted request-1/); + await assert.rejects(second, /aborted request-2/); + assert.equal(registry.pendingCount('turn-1'), 0); + }); +}); diff --git a/packages/runtime/src/permission-engine.ts b/packages/runtime/src/permission-engine.ts index 3e06bcb773..74ffc4b075 100644 --- a/packages/runtime/src/permission-engine.ts +++ b/packages/runtime/src/permission-engine.ts @@ -34,6 +34,7 @@ import { type ToolPermissionRule, } from '@maka/core/permission'; import type { PermissionDecisionAckEvent, PermissionRequestEvent } from '@maka/core/events'; +import { TurnScopedAwaitRegistry } from './turn-scoped-await-registry.js'; // ============================================================================ // Per-turn state @@ -43,18 +44,13 @@ interface TurnState { turnId: string; /** Tool-intent scopes granted with `rememberForTurn: true` in this turn. */ remembered: Set; - /** Outstanding parked permission requests, keyed by requestId. */ - parked: Map; } -interface ParkedRequest { - requestId: string; +interface ParkedPermission { toolUseId: string; category: ToolCategory; scopeKey: string; rememberForTurnAllowed: boolean; - resolve(response: PermissionResponse): void; - reject(err: Error): void; } // ============================================================================ @@ -117,25 +113,26 @@ export interface PermissionEngineDeps { export class PermissionEngine { private readonly turns = new Map(); + private readonly parked = new TurnScopedAwaitRegistry(); constructor(private readonly deps: PermissionEngineDeps) {} /** Begin tracking a new turn. Idempotent. */ beginTurn(turnId: string): void { if (!this.turns.has(turnId)) { - this.turns.set(turnId, { turnId, remembered: new Set(), parked: new Map() }); + this.turns.set(turnId, { turnId, remembered: new Set() }); } + this.parked.beginTurn(turnId); } /** End tracking, rejecting any still-parked requests as user_stop. */ endTurn(turnId: string, reason: 'completed' | 'aborted' = 'completed'): void { const state = this.turns.get(turnId); if (!state) return; - for (const parked of state.parked.values()) { - parked.reject( - new Error(`Turn ${turnId} ${reason} before permission request ${parked.requestId} was answered`), - ); - } + this.parked.endTurn( + turnId, + (requestId) => new Error(`Turn ${turnId} ${reason} before permission request ${requestId} was answered`), + ); this.turns.delete(turnId); } @@ -227,21 +224,11 @@ export class PermissionEngine { ...(input.hint !== undefined ? { hint: input.hint } : {}), }; - let resolveFn: (r: PermissionResponse) => void = () => {}; - let rejectFn: (e: Error) => void = () => {}; - const parked = new Promise((res, rej) => { - resolveFn = res; - rejectFn = rej; - }); - - state.parked.set(requestId, { - requestId, + const parked = this.parked.park(input.turnId, requestId, { toolUseId: input.toolUseId, category: pre.category, scopeKey: pre.scopeKey, rememberForTurnAllowed: pre.partialRequest.rememberForTurnAllowed !== false, - resolve: resolveFn, - reject: rejectFn, }); return { kind: 'prompt', category: pre.category, event, parked }; @@ -269,11 +256,9 @@ export class PermissionEngine { } const state = this.turns.get(turnId); if (!state) return null; - const parked = state.parked.get(response.requestId); + const parked = this.parked.entries(turnId).find(([requestId]) => requestId === response.requestId)?.[1]; if (!parked) return null; - state.parked.delete(response.requestId); - if ( response.decision === 'allow' && response.rememberForTurn @@ -285,16 +270,17 @@ export class PermissionEngine { // browser_* batch) must not each re-prompt. Resolve them now — each // tool's own coroutine then emits its own permission_decision_ack, so the // UI queue drains without a second click. The current request was already - // deleted above, so the snapshot never re-resolves it. - for (const [otherId, other] of [...state.parked]) { - if (other.scopeKey === parked.scopeKey) { - state.parked.delete(otherId); - other.resolve({ requestId: otherId, decision: 'allow', rememberForTurn: true }); + // selected explicitly, so the snapshot must not auto-resolve it. + for (const [otherId, other] of this.parked.entries(turnId)) { + if (otherId !== response.requestId && other.scopeKey === parked.scopeKey) { + this.parked.resolve(turnId, otherId, { requestId: otherId, decision: 'allow', rememberForTurn: true }); } } } - parked.resolve( + this.parked.resolve( + turnId, + response.requestId, parked.rememberForTurnAllowed ? response : { ...response, rememberForTurn: false }, @@ -308,26 +294,23 @@ export class PermissionEngine { * resolve a tool call that has already failed closed. */ expireRequest(turnId: string, requestId: string, reason: string): { category: ToolCategory; toolUseId: string } | null { - const state = this.turns.get(turnId); - if (!state) return null; - const parked = state.parked.get(requestId); + const parked = this.parked.reject(turnId, requestId, new Error(reason)); if (!parked) return null; - state.parked.delete(requestId); - parked.reject(new Error(reason)); return { category: parked.category, toolUseId: parked.toolUseId }; } /** Test/debug accessor. */ pendingCount(turnId: string): number { - return this.turns.get(turnId)?.parked.size ?? 0; + return this.parked.pendingCount(turnId); } private requireTurn(turnId: string): TurnState { let state = this.turns.get(turnId); if (!state) { // Auto-begin: callers may forget. This is a soft guarantee. - state = { turnId, remembered: new Set(), parked: new Map() }; + state = { turnId, remembered: new Set() }; this.turns.set(turnId, state); + this.parked.beginTurn(turnId); } return state; } diff --git a/packages/runtime/src/turn-scoped-await-registry.ts b/packages/runtime/src/turn-scoped-await-registry.ts new file mode 100644 index 0000000000..38af61c03e --- /dev/null +++ b/packages/runtime/src/turn-scoped-await-registry.ts @@ -0,0 +1,75 @@ +interface ParkedRequest { + metadata: TMetadata; + resolve(value: TValue): void; + reject(error: Error): void; +} + +/** + * Pure turn-scoped ownership for requests that park tool execution while a + * host waits for user input. Product policy stays with the owning caller. + */ +export class TurnScopedAwaitRegistry { + private readonly turns = new Map>>(); + + beginTurn(turnId: string): void { + if (!this.turns.has(turnId)) this.turns.set(turnId, new Map()); + } + + park(turnId: string, requestId: string, metadata: TMetadata): Promise { + const requests = this.requireTurn(turnId); + if (requests.has(requestId)) throw new Error(`Request ${requestId} is already parked`); + return new Promise((resolve, reject) => { + requests.set(requestId, { metadata, resolve, reject }); + }); + } + + resolve(turnId: string, requestId: string, value: TValue): TMetadata | null { + return this.resolveWith(turnId, requestId, () => value); + } + + resolveWith( + turnId: string, + requestId: string, + valueFor: (metadata: TMetadata) => TValue, + ): TMetadata | null { + const request = this.take(turnId, requestId); + if (!request) return null; + request.resolve(valueFor(request.metadata)); + return request.metadata; + } + + reject(turnId: string, requestId: string, error: Error): TMetadata | null { + const request = this.take(turnId, requestId); + if (!request) return null; + request.reject(error); + return request.metadata; + } + + endTurn(turnId: string, errorFor: (requestId: string) => Error): void { + const requests = this.turns.get(turnId); + if (!requests) return; + this.turns.delete(turnId); + for (const [requestId, request] of requests) request.reject(errorFor(requestId)); + } + + entries(turnId: string): ReadonlyArray { + return [...(this.turns.get(turnId) ?? [])].map(([requestId, request]) => [requestId, request.metadata] as const); + } + + pendingCount(turnId: string): number { + return this.turns.get(turnId)?.size ?? 0; + } + + private take(turnId: string, requestId: string): ParkedRequest | null { + const requests = this.turns.get(turnId); + const request = requests?.get(requestId); + if (!request) return null; + requests?.delete(requestId); + return request; + } + + private requireTurn(turnId: string): Map> { + this.beginTurn(turnId); + return this.turns.get(turnId)!; + } +} From a4aa30095afe2f59871d9b687c77179c65c294bc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 14 Jul 2026 02:16:30 +0800 Subject: [PATCH 2/6] feat(runtime): add AskUserQuestion round trip --- packages/core/package.json | 1 + packages/core/src/backend-types.ts | 2 + packages/core/src/events.ts | 6 + packages/core/src/index.ts | 8 ++ packages/core/src/runtime-event.ts | 3 + packages/core/src/user-question.ts | 25 ++++ packages/runtime/package.json | 1 + .../runtime/src/__tests__/ai-sdk-flow.test.ts | 32 +++++ .../src/__tests__/ask-user-question.test.ts | 122 ++++++++++++++++++ packages/runtime/src/agent-flow.ts | 2 + packages/runtime/src/ai-sdk-backend.ts | 11 +- packages/runtime/src/ai-sdk-flow.ts | 19 +++ .../runtime/src/ask-user-question-tool.ts | 32 +++++ packages/runtime/src/fake-backend.ts | 3 + packages/runtime/src/index.ts | 1 + packages/runtime/src/runtime-kernel.ts | 7 + packages/runtime/src/session-manager.ts | 5 + packages/runtime/src/tool-runtime.ts | 71 ++++++++++ 18 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/user-question.ts create mode 100644 packages/runtime/src/__tests__/ask-user-question.test.ts create mode 100644 packages/runtime/src/ask-user-question-tool.ts diff --git a/packages/core/package.json b/packages/core/package.json index 5f380d9b0c..d872c09f91 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "./permission-profile": "./dist/permission-profile.js", "./permission-profile-compiler": "./dist/permission-profile-compiler.js", "./permission-request-health": "./dist/permission-request-health.js", + "./user-question": "./dist/user-question.js", "./connections": "./dist/connections.js", "./workspace": "./dist/workspace.js", "./artifacts": "./dist/artifacts.js", diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 7f9b32c957..411990c0c6 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -12,6 +12,7 @@ import type { AttachmentRef, SessionEvent } from './events.js'; import type { RuntimeEvent } from './runtime-event.js'; import type { StoredMessage, BackendKind } from './session.js'; import type { PermissionResponse } from './permission.js'; +import type { UserQuestionResponse } from './user-question.js'; import type { ContextBudgetDiagnostic } from './usage-stats/types.js'; export interface BackendSendInput { @@ -55,5 +56,6 @@ export interface AgentBackend { compactHistory?(input: BackendCompactHistoryInput): Promise; stop(reason: 'user_stop' | 'redirect'): Promise; respondToPermission(decision: PermissionDecision): Promise; + respondToUserQuestion?(response: UserQuestionResponse): Promise; dispose(): Promise; } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 177985ebbd..1380d28cd9 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -8,6 +8,7 @@ */ import type { PermissionMode, PermissionRequest, PermissionResponse, ToolCategory } from './permission.js'; +import type { UserQuestionRequest } from './user-question.js'; import type { PipeShellOutput, PtyShellOutput, @@ -80,6 +81,7 @@ export type SessionEvent = | ToolResultEvent | PermissionRequestEvent | PermissionDecisionAckEvent + | UserQuestionRequestEvent | PlanSubmittedEvent | TokenUsageEvent | ErrorEvent @@ -409,6 +411,10 @@ export interface PermissionRequestEvent extends BaseEvent { rememberForTurnAllowed?: boolean; } +export interface UserQuestionRequestEvent extends BaseEvent, UserQuestionRequest { + type: 'user_question_request'; +} + /** * Echo of the user's permission decision back through the event stream so * all UI observers (and JSONL audit) see the same outcome. Mirrors the diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0aac89769c..7bc0bf64d7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export type { ShellRunUpdate, PermissionRequestEvent, PermissionDecisionAckEvent, + UserQuestionRequestEvent, PlanSubmittedEvent, PlanStep, TokenUsageEvent, @@ -40,6 +41,13 @@ export type { AttachmentIngestItem, CompleteStopReason, } from './events.js'; +export type { + UserQuestion, + UserQuestionOption, + UserQuestionRequest, + UserQuestionResponse, + UserQuestionResult, +} from './user-question.js'; export { failureClassFromCompleteStopReason, TOOL_ACTIVITY_KINDS, diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index ee9611bc54..0c57ecd8d8 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -16,6 +16,7 @@ import type { AttachmentRef } from './events.js'; import type { PermissionRequest, PermissionResponse } from './permission.js'; +import type { UserQuestionRequest } from './user-question.js'; import type { CacheMissInputSource, ContextBudgetDiagnostic, @@ -217,6 +218,8 @@ export interface RuntimeEventActions { permissionRequest?: PermissionRequest; /** A resolved permission decision (allow/deny) for a prior request. */ permissionDecision?: RuntimeEventPermissionDecision; + /** A bounded in-turn question raised by a tool call. */ + userQuestionRequest?: UserQuestionRequest; /** Hand off the invocation to another agent (multi-agent transfer). */ transferToAgent?: string; /** Marks the event that closes the invocation. */ diff --git a/packages/core/src/user-question.ts b/packages/core/src/user-question.ts new file mode 100644 index 0000000000..5af986d918 --- /dev/null +++ b/packages/core/src/user-question.ts @@ -0,0 +1,25 @@ +export interface UserQuestionOption { + label: string; + description?: string; +} + +export interface UserQuestion { + question: string; + options: UserQuestionOption[]; +} + +export interface UserQuestionRequest { + requestId: string; + toolUseId: string; + questions: UserQuestion[]; +} + +export interface UserQuestionResponse { + requestId: string; + /** One answer per question, in request order. `null` means unanswered. */ + answers: Array; +} + +export interface UserQuestionResult { + answers: Array<{ question: string; answer: string | null }>; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 3eeb931937..c3c1b296cc 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -9,6 +9,7 @@ "exports": { ".": "./dist/index.js", "./permission-engine": "./dist/permission-engine.js", + "./ask-user-question-tool": "./dist/ask-user-question-tool.js", "./ai-sdk-backend": "./dist/ai-sdk-backend.js", "./builtin-tools": "./dist/builtin-tools.js", "./shell-tools": "./dist/shell-tools.js", diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts index cad3c26efe..7b1afd654e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -668,6 +668,38 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { assert.deepEqual(a.actions?.stateDelta, { planId: 'p1', title: 'T', markdownPath: '/p.md' }); }); + test('user_question_request maps to one system-authored runtime action', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'user_question_request', + requestId: 'question-1', + toolUseId: 'tool-1', + questions: [{ + question: 'Choose an approach', + options: [ + { label: 'Extend', description: 'Reuse the runtime seam' }, + { label: 'Separate' }, + ], + }], + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'system'); + assert.deepEqual(mapped.actions?.userQuestionRequest, { + requestId: 'question-1', + toolUseId: 'tool-1', + questions: [{ + question: 'Choose an approach', + options: [ + { label: 'Extend', description: 'Reuse the runtime seam' }, + { label: 'Separate' }, + ], + }], + }); + }); + test('tool_result without a prior tool_start still maps (name falls back to empty)', () => { const a = mapSessionEventToRuntimeEvent( ev({ type: 'tool_result', toolUseId: 'orphan', isError: true, content: { kind: 'text', text: 'boom' } }), diff --git a/packages/runtime/src/__tests__/ask-user-question.test.ts b/packages/runtime/src/__tests__/ask-user-question.test.ts new file mode 100644 index 0000000000..24a3970a1b --- /dev/null +++ b/packages/runtime/src/__tests__/ask-user-question.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; +import type { SessionHeader, StoredMessage } from '@maka/core/session'; + +import { buildAskUserQuestionTool } from '../ask-user-question-tool.js'; +import { PermissionEngine } from '../permission-engine.js'; +import { ToolRuntime } from '../tool-runtime.js'; + +function header(): SessionHeader { + return { + id: 'session-1', workspaceRoot: '/tmp/maka', cwd: '/tmp/maka', createdAt: 1, + lastUsedAt: 1, name: 'Test', isFlagged: false, labels: [], isArchived: false, + status: 'active', statusUpdatedAt: 1, hasUnread: false, backend: 'ai-sdk', + llmConnectionSlug: 'c', connectionLocked: true, model: 'm', permissionMode: 'ask', + schemaVersion: 1, + }; +} + +describe('AskUserQuestion runtime round trip', () => { + test('parks the tool, emits one request, and persists one nullable JSON result', async () => { + const appended: StoredMessage[] = []; + const events: SessionEvent[] = []; + let id = 0; + const runtime = new ToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'c' } as never, + modelId: 'm', + appendMessage: async (message) => { appended.push(message); }, + permissionEngine: new PermissionEngine({ newId: () => `permission-${++id}`, now: () => 1 }), + newId: () => `id-${++id}`, + now: () => 1, + getPermissionPauseTarget: () => null, + }); + runtime.beginTurn('turn-1'); + const execute = runtime.wrapToolExecute(buildAskUserQuestionTool(), 'turn-1', { + push: (event) => events.push(event), + }); + + const resultPromise = execute({ + questions: [ + { + question: 'Choose an approach', + options: [ + { label: 'Extend', description: 'Reuse the runtime seam' }, + { label: 'Separate' }, + ], + }, + { + question: 'Keep the default?', + options: [{ label: 'Yes' }, { label: 'No' }], + }, + ], + }, { toolCallId: 'tool-1', abortSignal: new AbortController().signal }); + + await new Promise((resolve) => setImmediate(resolve)); + const request = events.find((event) => event.type === 'user_question_request'); + assert.ok(request); + assert.equal(request.toolUseId, 'tool-1'); + assert.equal(runtime.pendingUserQuestionCount('turn-1'), 1); + + assert.equal(runtime.respondToUserQuestion('turn-1', { + requestId: request.requestId, + answers: ['Extend', null], + }), true); + + assert.deepEqual(await resultPromise, { + answers: [ + { question: 'Choose an approach', answer: 'Extend' }, + { question: 'Keep the default?', answer: null }, + ], + }); + const results = appended.filter((message) => message.type === 'tool_result'); + assert.equal(results.length, 1); + assert.deepEqual(results[0]?.content, { + kind: 'json', + value: { + answers: [ + { question: 'Choose an approach', answer: 'Extend' }, + { question: 'Keep the default?', answer: null }, + ], + }, + }); + }); + + test('turn abort rejects the parked tool and ignores a late response', async () => { + const events: SessionEvent[] = []; + let id = 0; + const runtime = new ToolRuntime({ + sessionId: 'session-1', header: header(), + connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', + appendMessage: async () => {}, + permissionEngine: new PermissionEngine({ newId: () => `permission-${++id}`, now: () => 1 }), + newId: () => `id-${++id}`, now: () => 1, getPermissionPauseTarget: () => null, + }); + runtime.beginTurn('turn-1'); + const execute = runtime.wrapToolExecute(buildAskUserQuestionTool(), 'turn-1', { + push: (event) => events.push(event), + }); + const resultPromise = execute({ + questions: [{ + question: 'Continue?', + options: [{ label: 'Yes' }, { label: 'No' }], + }], + }, { toolCallId: 'tool-1', abortSignal: new AbortController().signal }); + + await new Promise((resolve) => setImmediate(resolve)); + const request = events.find((event) => event.type === 'user_question_request'); + assert.ok(request); + + runtime.endTurn('turn-1', 'aborted'); + + assert.deepEqual(await resultPromise, { + error: `Turn turn-1 aborted before user question ${request.requestId} was answered`, + }); + assert.equal(runtime.respondToUserQuestion('turn-1', { + requestId: request.requestId, + answers: ['Yes'], + }), false); + }); +}); diff --git a/packages/runtime/src/agent-flow.ts b/packages/runtime/src/agent-flow.ts index dd9b46939d..b310c13d34 100644 --- a/packages/runtime/src/agent-flow.ts +++ b/packages/runtime/src/agent-flow.ts @@ -128,6 +128,7 @@ export type RunnableAgentFlow = Pick; export interface AgentFlowControl { stop(reason: 'user_stop' | 'redirect'): Promise; respondToPermission(decision: import('@maka/core/backend-types').PermissionDecision): Promise; + respondToUserQuestion(response: import('@maka/core/user-question').UserQuestionResponse): Promise; dispose(): Promise; } @@ -139,6 +140,7 @@ export function flowSupportsControl(flow: AgentFlow): flow is AgentFlow & AgentF return ( typeof (flow as AgentFlow & Partial).stop === 'function' && typeof (flow as AgentFlow & Partial).respondToPermission === 'function' && + typeof (flow as AgentFlow & Partial).respondToUserQuestion === 'function' && typeof (flow as AgentFlow & Partial).dispose === 'function' ); } diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 4959b35472..4320968ed1 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -66,6 +66,7 @@ import type { AgentSpec } from '@maka/core/runtime-inputs'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ToolPermissionRule } from '@maka/core/permission'; +import type { UserQuestionResponse } from '@maka/core/user-question'; import type { LlmCallRecord, PricingConfig, @@ -740,6 +741,7 @@ export class AiSdkBackend implements AgentBackend { this.currentTurnId = turnId; this.currentRunId = input.runId ?? null; this.input.permissionEngine.beginTurn(turnId); + this.toolRuntime.beginTurn(turnId); this.abortController = new AbortController(); const queue = new AsyncEventQueue(); @@ -876,7 +878,6 @@ export class AiSdkBackend implements AgentBackend { // .return()-ing) the generator; resetting here makes each turn's state — the // loop-gate streak, subagent count, gating — depend only on this turn, not on // the previous turn's teardown. - this.toolRuntime.resetTurnState(); if (plan.gating) { this.toolRuntime.setGating(plan.gating); } @@ -1372,6 +1373,7 @@ export class AiSdkBackend implements AgentBackend { this.historyCompactAbortController?.abort(); if (this.currentTurnId !== null) { this.input.permissionEngine.endTurn(this.currentTurnId, 'aborted'); + this.toolRuntime.endTurn(this.currentTurnId, 'aborted'); } this.currentRunTrace?.abortRequested(_reason); } @@ -1383,6 +1385,11 @@ export class AiSdkBackend implements AgentBackend { // after parked.resolve() returns, so no further work here. } + async respondToUserQuestion(response: UserQuestionResponse): Promise { + if (this.currentTurnId === null) return; + this.toolRuntime.respondToUserQuestion(this.currentTurnId, response); + } + async dispose(): Promise { if (!this.aborted) await this.stop('user_stop'); } @@ -2773,7 +2780,7 @@ export class AiSdkBackend implements AgentBackend { this.currentRunId = null; this.currentRunTrace = null; this.currentStepMessageId = null; - this.toolRuntime.resetTurnState(); + this.toolRuntime.endTurn(turnId, this.aborted ? 'aborted' : 'completed'); this.aborted = false; } } diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index d5ba84530b..f5407a3490 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -36,6 +36,7 @@ import { type SessionEvent, } from '@maka/core/events'; import type { PermissionDecision } from '@maka/core/backend-types'; +import type { UserQuestionResponse } from '@maka/core/user-question'; import { isTerminalRuntimeEvent, type RuntimeEvent, type RuntimeEventStatus } from '@maka/core/runtime-event'; import type { AgentBackend } from '@maka/core/backend-types'; @@ -275,6 +276,20 @@ export function mapSessionEventToRuntimeEvent( }, refs: { toolCallId: event.toolUseId }, }; + case 'user_question_request': + return { + ...base, + role: 'system', + author: 'system', + actions: { + userQuestionRequest: { + requestId: event.requestId, + toolUseId: event.toolUseId, + questions: event.questions, + }, + }, + refs: { toolCallId: event.toolUseId }, + }; // ── Plan handoff (placeholder; Phase 5/7 refines) ───────────────────── case 'plan_submitted': @@ -543,6 +558,10 @@ export class AiSdkFlow implements AgentFlow, AgentFlowControl { await this.backend.respondToPermission(decision); } + async respondToUserQuestion(response: UserQuestionResponse): Promise { + await this.backend.respondToUserQuestion?.(response); + } + async dispose(): Promise { await this.backend.dispose(); } diff --git a/packages/runtime/src/ask-user-question-tool.ts b/packages/runtime/src/ask-user-question-tool.ts new file mode 100644 index 0000000000..5011da376f --- /dev/null +++ b/packages/runtime/src/ask-user-question-tool.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import type { UserQuestion, UserQuestionResult } from '@maka/core/user-question'; + +import type { MakaTool } from './tool-runtime.js'; + +const optionSchema = z.object({ + label: z.string().min(1), + description: z.string().min(1).optional(), +}); + +const questionSchema = z.object({ + question: z.string().min(1), + options: z.array(optionSchema).min(2).max(3), +}); + +export function buildAskUserQuestionTool(): MakaTool< + { questions: UserQuestion[] }, + UserQuestionResult +> { + return { + name: 'AskUserQuestion', + description: 'Ask 1–3 bounded multiple-choice questions whose answers are required to continue the current turn. Use ordinary assistant text for open-ended follow-up.', + parameters: z.object({ + questions: z.array(questionSchema).min(1).max(3), + }), + permissionRequired: false, + impl: ({ questions }, context) => { + if (!context.askUserQuestion) throw new Error('AskUserQuestion is unavailable on this surface'); + return context.askUserQuestion(questions); + }, + }; +} diff --git a/packages/runtime/src/fake-backend.ts b/packages/runtime/src/fake-backend.ts index b7a07e3c20..6ff63b2d74 100644 --- a/packages/runtime/src/fake-backend.ts +++ b/packages/runtime/src/fake-backend.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { BackendKind, SessionEvent, SessionHeader, StoredMessage } from '@maka/core'; import type { AgentBackend, BackendSendInput, PermissionDecision } from '@maka/core/backend-types'; +import type { UserQuestionResponse } from '@maka/core/user-question'; import type { SessionStore } from './session-manager.js'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -61,5 +62,7 @@ export class FakeBackend implements AgentBackend { async respondToPermission(_decision: PermissionDecision): Promise {} + async respondToUserQuestion(_response: UserQuestionResponse): Promise {} + async dispose(): Promise {} } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 126c0d508b..2a38d5aef2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -25,6 +25,7 @@ export type { EvaluateResult, EvaluateInput, PermissionEngineDeps } from './perm export { AiSdkBackend } from './ai-sdk-backend.js'; export type { MakaTool, MakaToolContext } from './tool-runtime.js'; +export { buildAskUserQuestionTool } from './ask-user-question-tool.js'; export type { AgentBackend, BackendCompactHistoryInput, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 4df6bcfefa..c902fc90c9 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -12,6 +12,7 @@ import type { import { isDeepStrictEqual } from 'node:util'; import type { ChildAgentTurnInput, UserMessageInput } from '@maka/core/runtime-inputs'; import type { PermissionResponse } from '@maka/core/permission'; +import type { UserQuestionResponse } from '@maka/core/user-question'; import { AgentRun, type AgentRunActiveSession, type AgentRunBeginResult, type AgentRunLineage } from './agent-run.js'; import { AiSdkFlow, mapSessionEventToRuntimeEvent } from './ai-sdk-flow.js'; import type { AgentBackend } from '@maka/core/backend-types'; @@ -44,6 +45,7 @@ export interface RuntimeKernelLike { startChildTurn(sessionId: string, input: ChildAgentTurnInput): AsyncIterable; stopSession(sessionId: string, input?: StopSessionInput): Promise; respondToPermission(sessionId: string, response: PermissionResponse): Promise; + respondToUserQuestion?(sessionId: string, response: UserQuestionResponse): Promise; hasActiveRuns(sessionId: string): boolean; updateCachedHeader(sessionId: string, header: SessionHeader): void; disposeBackend(sessionId: string): Promise; @@ -510,6 +512,11 @@ export class RuntimeKernel implements RuntimeKernelLike { await Promise.all(activeSessions.map((active) => active.backend.respondToPermission(response))); } + async respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { + const activeSessions = this.activeSessionsFor(sessionId); + await Promise.all(activeSessions.map((active) => active.backend.respondToUserQuestion?.(response))); + } + hasActiveRuns(sessionId: string): boolean { return this.activeSessionsFor(sessionId).some((active) => active.activeRuns.size > 0); } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 41d83e6a5b..23dcdafcb2 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -42,6 +42,7 @@ import type { SessionListFilter, } from '@maka/core/runtime-inputs'; import type { PermissionResponse } from '@maka/core/permission'; +import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; import { DEEP_RESEARCH_SESSION_LABEL, @@ -774,6 +775,10 @@ export class SessionManager { await this.runtimeKernel.respondToPermission(sessionId, response); } + async respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { + await this.runtimeKernel.respondToUserQuestion?.(sessionId, response); + } + // -------------------------------------------------------------------------- // Internal helpers // -------------------------------------------------------------------------- diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 79f9d6b7a3..e02a4e5b2a 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -21,6 +21,11 @@ import type { ToolPermissionRule, } from '@maka/core/permission'; import type { LlmConnection } from '@maka/core/llm-connections'; +import type { + UserQuestion, + UserQuestionResponse, + UserQuestionResult, +} from '@maka/core/user-question'; import { computerUseApprovalSummary } from '@maka/core'; import type { SessionHeader } from '@maka/core/session'; import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; @@ -36,6 +41,7 @@ import { createToolOutputDeltaEmitter } from './tool-output-delta.js'; import { truncateToolOutput } from './tool-output.js'; import { stableHash } from './request-shape.js'; import type { RunTraceLike } from './run-trace.js'; +import { TurnScopedAwaitRegistry } from './turn-scoped-await-registry.js'; export type ToolModelOutputPart = | { type: 'text'; text: string } @@ -103,6 +109,7 @@ export interface MakaToolContext { }) => Promise; listChildAgents?: () => Promise; readChildAgentOutput?: (input: { runId?: string; turnId?: string; maxEvents?: number }) => Promise; + askUserQuestion?: (questions: UserQuestion[]) => Promise; } export type AppendMessageFn = (m: ToolCallMessage | ToolResultMessage | PermissionDecisionMessage) => Promise; @@ -172,6 +179,10 @@ export interface ToolRuntimeInput { } export class ToolRuntime { + private readonly userQuestions = new TurnScopedAwaitRegistry< + UserQuestionResponse, + { toolUseId: string; questions: UserQuestion[] } + >(); private activeSubagentToolCount = 0; /** * Tool-availability gating for the execute boundary. Set by the backend each @@ -191,6 +202,39 @@ export class ToolRuntime { constructor(private readonly input: ToolRuntimeInput) {} + beginTurn(turnId: string): void { + this.resetTurnState(); + this.userQuestions.beginTurn(turnId); + } + + endTurn(turnId: string, reason: 'completed' | 'aborted' = 'completed'): void { + this.userQuestions.endTurn( + turnId, + (requestId) => new Error(`Turn ${turnId} ${reason} before user question ${requestId} was answered`), + ); + this.resetTurnState(); + } + + respondToUserQuestion(turnId: string, response: UserQuestionResponse): boolean { + if (!response || typeof response.requestId !== 'string' || !Array.isArray(response.answers)) { + throw new Error('Invalid user question response'); + } + const pending = this.userQuestions.entries(turnId) + .find(([requestId]) => requestId === response.requestId)?.[1]; + if (!pending) return false; + if ( + response.answers.length !== pending.questions.length + || response.answers.some((answer) => answer !== null && (typeof answer !== 'string' || answer.length === 0)) + ) { + throw new Error('Invalid user question response'); + } + return this.userQuestions.resolve(turnId, response.requestId, response) !== null; + } + + pendingUserQuestionCount(turnId: string): number { + return this.userQuestions.pendingCount(turnId); + } + wrapToolExecute( tool: MakaTool, turnId: string, @@ -561,6 +605,7 @@ export class ToolRuntime { ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}), ...(this.input.readChildAgentOutput ? { readChildAgentOutput: this.input.readChildAgentOutput } : {}), ...(this.buildSpawnChildAgentContext(ctx.abortSignal)), + askUserQuestion: (questions) => this.askUserQuestion(turnId, toolUseId, questions, queue), }); output.flush(); const durationMs = this.input.now() - startedAt; @@ -782,6 +827,32 @@ export class ToolRuntime { }) ?? Promise.reject(new Error('spawnChildAgent is unavailable')), }; } + + private async askUserQuestion( + turnId: string, + toolUseId: string, + questions: UserQuestion[], + queue: AsyncEventQueue | { push(event: SessionEvent): void }, + ): Promise { + const requestId = this.input.newId(); + const parked = this.userQuestions.park(turnId, requestId, { toolUseId, questions }); + queue.push({ + type: 'user_question_request', + id: this.input.newId(), + turnId, + ts: this.input.now(), + requestId, + toolUseId, + questions, + }); + const response = await parked; + return { + answers: questions.map((question, index) => ({ + question: question.question, + answer: response.answers[index] ?? null, + })), + }; + } } /** From 2e6a7e283422c934727ff9d9a87fc98ef70296c2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 14 Jul 2026 02:16:36 +0800 Subject: [PATCH 3/6] feat(cli): scope AskUserQuestion to TUI --- .../src/__tests__/runtime-bootstrap.test.ts | 52 +++++++++++++++++-- packages/cli/src/cli.ts | 1 + packages/cli/src/run-command.ts | 1 + packages/cli/src/runtime-bootstrap.ts | 7 ++- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-bootstrap.test.ts b/packages/cli/src/__tests__/runtime-bootstrap.test.ts index 74ffe88cb8..ae787dd091 100644 --- a/packages/cli/src/__tests__/runtime-bootstrap.test.ts +++ b/packages/cli/src/__tests__/runtime-bootstrap.test.ts @@ -29,6 +29,7 @@ describe('Maka CLI runtime bootstrap', () => { }); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo', }); @@ -74,6 +75,7 @@ describe('Maka CLI runtime bootstrap', () => { ] as const; const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo', requestedConnectionSlug: 'selected-local', @@ -131,6 +133,7 @@ describe('Maka CLI runtime bootstrap', () => { permissionMode: 'explore', }); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/canonical-repo', requestedConnectionSlug: 'local', @@ -167,6 +170,7 @@ describe('Maka CLI runtime bootstrap', () => { }); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo', }); @@ -180,6 +184,38 @@ describe('Maka CLI runtime bootstrap', () => { }); }); + test('registers AskUserQuestion only for the interactive TUI surface', async () => { + await withWorkspace(async (workspaceRoot) => { + const connectionStore = createConnectionStore(workspaceRoot); + await connectionStore.create({ + slug: 'local', + name: 'Local Ollama', + providerType: 'ollama', + defaultModel: 'llama3.2', + }); + + const tui = await createMakaCliRuntimeContext({ + workspaceRoot, + cwd: '/repo', + surface: 'tui', + }); + const run = await createMakaCliRuntimeContext({ + workspaceRoot, + cwd: '/repo', + surface: 'run', + }); + try { + const tool = tui.tools.find((candidate) => candidate.name === 'AskUserQuestion'); + assert.ok(tool); + assert.equal(tool.permissionRequired, false); + assert.equal(run.tools.some((candidate) => candidate.name === 'AskUserQuestion'), false); + } finally { + await tui.close(); + await run.close(); + } + }); + }); + test('registers the Skill tool so the CLI can load workspace skills', async () => { await withWorkspace(async (workspaceRoot) => { const connectionStore = createConnectionStore(workspaceRoot); @@ -191,6 +227,7 @@ describe('Maka CLI runtime bootstrap', () => { }); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo', }); @@ -214,6 +251,7 @@ describe('Maka CLI runtime bootstrap', () => { }); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: workspaceRoot, }); @@ -289,7 +327,8 @@ describe('Maka CLI runtime bootstrap', () => { await connectionStore.create({ slug: 'local', name: 'Local Ollama', providerType: 'ollama', defaultModel: 'llama3.2', }); - const context = await createMakaCliRuntimeContext({ workspaceRoot, cwd: workspaceRoot }); + const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: workspaceRoot }); const updates: ShellRunUpdate[] = []; const unsubscribe = context.subscribeShellRunUpdates((update) => updates.push(update)); try { @@ -326,7 +365,8 @@ describe('Maka CLI runtime bootstrap', () => { await connectionStore.create({ slug: 'local', name: 'Local Ollama', providerType: 'ollama', defaultModel: 'llama3.2', }); - const context = await createMakaCliRuntimeContext({ workspaceRoot, cwd: workspaceRoot }); + const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: workspaceRoot }); try { const parent = await context.runtime.createSession({ cwd: workspaceRoot, backend: 'ai-sdk', llmConnectionSlug: 'local', @@ -362,7 +402,8 @@ describe('Maka CLI runtime bootstrap', () => { await connectionStore.create({ slug: 'local', name: 'Local Ollama', providerType: 'ollama', defaultModel: 'llama3.2', }); - const context = await createMakaCliRuntimeContext({ workspaceRoot, cwd: workspaceRoot }); + const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: workspaceRoot }); try { const bash = context.tools.find((tool) => tool.name === 'Bash'); assert.ok(bash); @@ -407,6 +448,7 @@ describe('Maka CLI runtime bootstrap', () => { }); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo', }); @@ -457,7 +499,8 @@ describe('Maka CLI runtime bootstrap', () => { defaultModel: 'llama3.2', }); - const context = await createMakaCliRuntimeContext({ workspaceRoot, cwd: '/repo' }); + const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo' }); const session = await context.runtime.createSession({ cwd: context.cwd, backend: 'ai-sdk', @@ -501,6 +544,7 @@ describe('Maka CLI runtime bootstrap', () => { await credentialStore.setSecret('deepseek', 'api_key', 'test-key'); const context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: '/repo', }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3abf618785..d98ab74b97 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -110,6 +110,7 @@ export async function runMakaCli(argv: string[] = process.argv.slice(2)): Promis let context; try { context = await createMakaCliRuntimeContext({ + surface: 'tui', workspaceRoot, cwd: process.cwd(), }); diff --git a/packages/cli/src/run-command.ts b/packages/cli/src/run-command.ts index d692197d14..f46d294bc7 100644 --- a/packages/cli/src/run-command.ts +++ b/packages/cli/src/run-command.ts @@ -245,6 +245,7 @@ export async function runMakaTextCli( let context: MakaRunContext; try { context = await deps.createContext({ + surface: 'run', workspaceRoot, cwd: selection.cwd, ...((selection.kind === 'existing' || parsed.options.connection) diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 89e7788e19..1036b12094 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -11,6 +11,7 @@ import { SessionManager, ShellRunProcessManager, buildAutomationTool, + buildAskUserQuestionTool, buildBuiltinTools, createBuiltinSandboxManager, buildDefaultContextBudgetPolicy, @@ -67,6 +68,7 @@ export interface MakaCliRuntimeContext { } export interface CreateMakaCliRuntimeContextInput { + surface: 'tui' | 'run'; workspaceRoot: string; cwd: string; requestedConnectionSlug?: string; @@ -196,12 +198,13 @@ export async function createMakaCliRuntimeContext( // names registered on this host. The CLI has no Office tools, so bundled // Office skills (requiredTools includes OfficeDocument/OfficeDocumentEdit) // are hard-hidden here without seeding them — desktop owns Office seeding. + const surfaceTools = input.surface === 'tui' ? [buildAskUserQuestionTool()] : []; const host: HostCapabilities = { - toolNames: new Set([...tools, automationTool, ...goalTools].map((tool) => tool.name)), + toolNames: new Set([...tools, automationTool, ...goalTools, ...surfaceTools].map((tool) => tool.name)), }; const skillSource = resolveSkillDiscoveryPaths(input.cwd, input.workspaceRoot); const skillTool = buildSkillAgentTool(skillSource, host); - const allTools = [...tools, automationTool, ...goalTools, skillTool]; + const allTools = [...tools, automationTool, ...goalTools, skillTool, ...surfaceTools]; backends.register('ai-sdk', async (ctx) => { const header = input.sessionCwdOverride?.sessionId === ctx.sessionId From 2ed8a48b7b7733b97a2cb3d3b1d31b187a0d599f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 14 Jul 2026 02:22:23 +0800 Subject: [PATCH 4/6] feat(cli): render AskUserQuestion in TUI --- .../cli/src/__tests__/pi-transcript.test.ts | 23 ++- .../cli/src/__tests__/pi-tui-runner.test.ts | 95 +++++++++++++ .../cli/src/__tests__/session-driver.test.ts | 25 ++++ packages/cli/src/pi-transcript.ts | 76 ++++++++-- packages/cli/src/pi-tui-pickers.ts | 52 ++++++- packages/cli/src/pi-tui-runner.ts | 134 +++++++++++++++++- packages/cli/src/session-driver.ts | 9 ++ 7 files changed, 392 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index c24fe88580..abf58a2dcf 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -655,7 +655,7 @@ describe('Maka Pi TUI transcript', () => { permissionMode: 'ask', }, 100).map(stripAnsi); - assert.equal(state.pendingPermission?.requestId, 'permission-1'); + assert.equal(state.pendingInteraction?.requestId, 'permission-1'); assert.ok(visibleLines.some((line) => line.includes('Permission required'))); assert.ok(visibleLines.some((line) => line.includes('Bash'))); assert.ok(visibleLines.some((line) => line.includes('npm test'))); @@ -663,6 +663,27 @@ describe('Maka Pi TUI transcript', () => { assert.ok(visibleLines.some((line) => line.includes('n/Esc deny'))); }); + test('queues permission and user-question requests in arrival order', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript(state, event({ + type: 'permission_request', requestId: 'permission-1', toolUseId: 'tool-1', + toolName: 'Bash', category: 'shell_unsafe', reason: 'shell_dangerous', args: {}, + })); + applyMakaSessionEventToTranscript(state, event({ + type: 'user_question_request', requestId: 'question-1', toolUseId: 'tool-2', + questions: [{ question: 'Choose', options: [{ label: 'A' }, { label: 'B' }] }], + })); + + assert.equal(state.pendingInteraction?.requestId, 'permission-1'); + assert.deepEqual(state.queuedInteractions.map((item) => item.requestId), ['question-1']); + + applyMakaSessionEventToTranscript(state, event({ + type: 'permission_decision_ack', requestId: 'permission-1', toolUseId: 'tool-1', decision: 'allow', + })); + assert.equal(state.pendingInteraction?.requestId, 'question-1'); + assert.deepEqual(state.queuedInteractions, []); + }); + test('orders thinking entries by arrival, before text and around tools', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index a6a93a1b5f..d83006c741 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -12,6 +12,7 @@ import { type SessionSummary, type StoredMessage, type ThinkingLevel, + type UserQuestionResponse, } from '@maka/core'; import type { ShellRunUpdate } from '@maka/runtime'; import type { MakaSessionDriver, MakaSessionRewindResult, MakaSessionSwitchResult, RewindTarget } from '../session-driver.js'; @@ -280,6 +281,63 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('answers sequential questions with a choice, Escape, and Other input', async () => { + const terminal = new FakeTerminal(); + const driver = new UserQuestionPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', driver, cwd: '/repo', model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', permissionMode: 'ask', terminal, + }); + + terminal.input('choose'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Choose an approach')); + assertBottomPickerPlacement( + terminal, + 'Choose an approach', + 'Maka claude-sonnet-4-5 claude-subscription ask /repo', + ); + + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Keep the default')); + terminal.input('\x1b'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Anything else')); + terminal.input('\x1b[B'); + terminal.input('\x1b[B'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Type another answer')); + terminal.input('Use the existing seam'); + terminal.input('\r'); + + await waitFor(() => driver.responses.length === 1); + assert.deepEqual(driver.responses, [{ + requestId: 'question-1', + answers: ['Extend', null, 'Use the existing seam'], + }]); + + exitMaka(terminal); + await run; + }); + + test('Ctrl-C stops a turn while a user-question overlay is open', async () => { + const terminal = new FakeTerminal(); + const driver = new UserQuestionPromptDriver(); + const run = runMakaPiTui({ + title: 'Maka', driver, cwd: '/repo', model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', permissionMode: 'ask', terminal, + }); + + terminal.input('choose'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Choose an approach')); + terminal.input('\x03'); + + await waitFor(() => driver.stopCalls === 1); + assert.deepEqual(driver.responses, []); + exitMaka(terminal); + await run; + }); + test('toggles tool detail globally with Ctrl-O', async () => { const terminal = new FakeTerminal(); const driver = new ToolOutputDriver(); @@ -2982,6 +3040,43 @@ class PermissionPromptDriver implements MakaSessionDriver { } } +class UserQuestionPromptDriver implements MakaSessionDriver { + readonly responses: UserQuestionResponse[] = []; + stopCalls = 0; + private release: (() => void) | undefined; + + async listSessions(): Promise { return []; } + async *compactSession(): AsyncIterable {} + async *sendPrompt(_prompt: string): AsyncIterable { + yield { + type: 'user_question_request', id: 'event-question', turnId: 'turn-1', ts: 1, + requestId: 'question-1', toolUseId: 'tool-1', + questions: [ + { question: 'Choose an approach', options: [{ label: 'Extend', description: 'Reuse the seam' }, { label: 'Separate' }] }, + { question: 'Keep the default', options: [{ label: 'Yes' }, { label: 'No' }] }, + { question: 'Anything else', options: [{ label: 'Nothing' }, { label: 'More detail' }] }, + ], + }; + await new Promise((resolve) => { this.release = resolve; }); + yield { type: 'complete', id: 'complete-1', turnId: 'turn-1', ts: 2, stopReason: 'end_turn' }; + } + async respondToUserQuestion(response: UserQuestionResponse): Promise { + this.responses.push(response); + this.release?.(); + } + async stop(): Promise { this.stopCalls += 1; this.release?.(); } + async respondToPermission(_response: PermissionResponse): Promise {} + async renameSession(): Promise {} + async setModel(): Promise {} + async setPermissionMode(): Promise {} + async setThinkingLevel(): Promise {} + async switchSession(sessionId: string): Promise { return switchResult(fakeSessionSummary(sessionId)); } + async listRewindTargets(): Promise { return []; } + async rewindToTurn(): Promise { throw new Error('rewind not supported'); } + startNewSession(): void {} + getSessionId(): string { return 'session-1'; } +} + class InterruptibleTurnDriver implements MakaSessionDriver { stopCalls = 0; private releaseTurn: (() => void) | null = null; diff --git a/packages/cli/src/__tests__/session-driver.test.ts b/packages/cli/src/__tests__/session-driver.test.ts index 81ca8a2eb3..8420b4149b 100644 --- a/packages/cli/src/__tests__/session-driver.test.ts +++ b/packages/cli/src/__tests__/session-driver.test.ts @@ -11,6 +11,7 @@ import type { SessionSummary, StoredMessage, UserMessageInput, + UserQuestionResponse, } from '@maka/core'; import { createMakaSessionDriver } from '../session-driver.js'; @@ -438,6 +439,25 @@ describe('Maka session driver', () => { }]); }); + test('routes user-question responses to the active session', async () => { + const runtime = new RecordingRuntime(); + const driver = createMakaSessionDriver({ + runtime, + cwd: '/repo', + llmConnectionSlug: 'anthropic', + model: 'claude-sonnet-4-5', + newId: nextId('turn'), + }); + + await collect(driver.sendPrompt('choose')); + await driver.respondToUserQuestion?.({ requestId: 'question-1', answers: ['A', null] }); + + assert.deepEqual(runtime.userQuestionResponses, [{ + sessionId: 'session-1', + response: { requestId: 'question-1', answers: ['A', null] }, + }]); + }); + test('lists rewind targets newest-first, one per prompted turn, including the latest', async () => { const runtime = new RecordingRuntime(); const driver = createMakaSessionDriver({ @@ -653,6 +673,7 @@ class RecordingRuntime { readonly sent: Array<{ sessionId: string; input: UserMessageInput }> = []; readonly compacted: Array<{ sessionId: string; input: { turnId?: string } }> = []; readonly permissionResponses: Array<{ sessionId: string; response: PermissionResponse }> = []; + readonly userQuestionResponses: Array<{ sessionId: string; response: UserQuestionResponse }> = []; readonly permissionModes: Array<{ sessionId: string; mode: PermissionMode }> = []; readonly sessionUpdates: Array<{ sessionId: string; patch: { model?: string; llmConnectionSlug?: string; thinkingLevel?: import('@maka/core/model-thinking').ThinkingLevel | undefined; name?: string } }> = []; readonly branched: Array<{ sessionId: string; sourceTurnId: string }> = []; @@ -713,6 +734,10 @@ class RecordingRuntime { this.permissionResponses.push({ sessionId, response }); } + async respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise { + this.userQuestionResponses.push({ sessionId, response }); + } + async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { this.permissionModes.push({ sessionId, mode }); return { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 96f2c8367b..81a6feb621 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1,6 +1,7 @@ import { Markdown } from '@earendil-works/pi-tui'; import type { PermissionRequestEvent, + UserQuestionRequestEvent, SessionEvent, ToolOutputStream, ToolResultContent, @@ -32,7 +33,8 @@ import { renderToolBlock } from './pi-transcript-tools.js'; export interface MakaPiTranscriptState { entries: MakaPiTranscriptEntry[]; sawTextDeltaMessageIds: Set; - pendingPermission?: PermissionRequestEvent; + pendingInteraction?: MakaPiPendingInteraction; + queuedInteractions: MakaPiPendingInteraction[]; /** * Global expansion toggles: one Ctrl+O press expands every tool card in the * transcript, one Ctrl+T press expands every thinking entry; pressing again @@ -43,6 +45,8 @@ export interface MakaPiTranscriptState { expandAllThinking: boolean; } +export type MakaPiPendingInteraction = PermissionRequestEvent | UserQuestionRequestEvent; + /** A single live output chunk from a `tool_output_delta` event. */ export interface MakaPiToolOutputDelta { seq: number; @@ -93,6 +97,7 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { return { entries: [], sawTextDeltaMessageIds: new Set(), + queuedInteractions: [], expandAllTools: false, expandAllThinking: false, }; @@ -156,7 +161,7 @@ export function replaceTranscriptWithStoredMessages( .filter((entry): entry is Extract => entry.kind === 'assistant') .map((entry) => entry.messageId), ); - state.pendingPermission = undefined; + clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; } @@ -385,19 +390,23 @@ export function applyMakaSessionEventToTranscript( } case 'permission_request': - state.pendingPermission = event; + case 'user_question_request': + enqueuePendingInteraction(state, event); break; case 'permission_decision_ack': - if (state.pendingPermission?.requestId === event.requestId) { - const toolName = state.pendingPermission.toolName; - state.pendingPermission = undefined; + { + const request = findPendingInteraction(state, event.requestId); + if (request?.type === 'permission_request') { + completePendingInteraction(state, event.requestId); + const toolName = request.toolName; state.entries.push({ kind: 'notice', level: 'info', text: `Permission ${event.decision}ed for ${toolName}`, }); } + } break; case 'plan_submitted': @@ -421,7 +430,7 @@ export function applyMakaSessionEventToTranscript( } case 'error': - state.pendingPermission = undefined; + clearPendingInteractions(state); state.entries.push({ kind: 'notice', level: 'error', @@ -430,7 +439,7 @@ export function applyMakaSessionEventToTranscript( break; case 'abort': - state.pendingPermission = undefined; + clearPendingInteractions(state); state.entries.push({ kind: 'notice', level: 'info', @@ -440,7 +449,7 @@ export function applyMakaSessionEventToTranscript( case 'complete': // The turn is over; any unresolved permission request is no longer actionable. - state.pendingPermission = undefined; + clearPendingInteractions(state); if (event.stopReason === 'max_tokens') { state.entries.push({ kind: 'notice', @@ -672,7 +681,7 @@ export function renderMakaPiTranscript( // A fresh session (no history, nothing pending) opens on a welcome block so the // first screen greets and orients instead of showing an empty pane. Once the // first prompt lands, entries take over and it never renders again. - if (state.entries.length === 0 && !state.pendingPermission) { + if (state.entries.length === 0 && !state.pendingInteraction) { return renderWelcomeBlock(metadata, safeWidth); } @@ -682,14 +691,57 @@ export function renderMakaPiTranscript( lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, state.expandAllTools, state.expandAllThinking)); } - if (state.pendingPermission) { + if (state.pendingInteraction?.type === 'permission_request') { lines.push(''); - lines.push(...renderPermissionPrompt(state.pendingPermission, safeWidth)); + lines.push(...renderPermissionPrompt(state.pendingInteraction, safeWidth)); } return lines; } +export function completePendingInteraction( + state: MakaPiTranscriptState, + requestId: string, +): boolean { + if (state.pendingInteraction?.requestId === requestId) { + state.pendingInteraction = state.queuedInteractions.shift(); + return true; + } + const index = state.queuedInteractions.findIndex((request) => request.requestId === requestId); + if (index < 0) return false; + state.queuedInteractions.splice(index, 1); + return true; +} + +export function activePermissionRequest(state: MakaPiTranscriptState): PermissionRequestEvent | undefined { + return state.pendingInteraction?.type === 'permission_request' ? state.pendingInteraction : undefined; +} + +export function activeUserQuestionRequest(state: MakaPiTranscriptState): UserQuestionRequestEvent | undefined { + return state.pendingInteraction?.type === 'user_question_request' ? state.pendingInteraction : undefined; +} + +function enqueuePendingInteraction( + state: MakaPiTranscriptState, + request: MakaPiPendingInteraction, +): void { + if (!state.pendingInteraction) state.pendingInteraction = request; + else state.queuedInteractions.push(request); +} + +function findPendingInteraction( + state: MakaPiTranscriptState, + requestId: string, +): MakaPiPendingInteraction | undefined { + if (state.pendingInteraction?.requestId === requestId) return state.pendingInteraction; + return state.queuedInteractions.find((request) => request.requestId === requestId); +} + +function clearPendingInteractions(state: MakaPiTranscriptState): void { + state.pendingInteraction = undefined; + state.queuedInteractions = []; +} + /** * Per-entry render cache. The transcript re-renders on every keystroke and * stream delta, but only the tail entry actually changes; caching the rendered diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index e421c66254..154ea5154b 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -1,6 +1,9 @@ import { CombinedAutocompleteProvider, + Editor, + Key, SelectList, + matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, @@ -8,11 +11,12 @@ import { type AutocompleteSuggestions, type Component, type SelectItem, + type TUI, } from '@earendil-works/pi-tui'; import { PERMISSION_MODES, type PermissionMode } from '@maka/core/permission'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { ModelChoice } from './connection-target.js'; -import { ansi, stripAnsi } from './tui-ansi.js'; +import { ansi, editorTheme, stripAnsi } from './tui-ansi.js'; export class MakaAutocompleteProvider implements AutocompleteProvider { private readonly fileProvider: CombinedAutocompleteProvider; @@ -88,7 +92,7 @@ function slashCommandPrefix(lines: string[], cursorLine: number, cursorCol: numb export class PickerOverlay implements Component { constructor( private readonly list: SelectList, - private readonly input: { title: string; rightLabel: string }, + private readonly input: { title: string; rightLabel: string; hint?: string }, ) {} invalidate(): void { @@ -103,7 +107,7 @@ export class PickerOverlay implements Component { const safeWidth = Math.max(1, width); return [ padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), - padLine(ansi.dim('enter select / esc close'), safeWidth), + padLine(ansi.dim(this.input.hint ?? 'enter select / esc close'), safeWidth), padLine('', safeWidth), ...this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth)), padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), @@ -111,6 +115,48 @@ export class PickerOverlay implements Component { } } +export class UserQuestionTextOverlay implements Component { + private readonly editor: Editor; + + constructor( + tui: TUI, + private readonly input: { + title: string; + rightLabel: string; + onSubmit(value: string): void; + onSkip(): void; + }, + ) { + this.editor = new Editor(tui, editorTheme(), { paddingX: 1 }); + this.editor.onSubmit = (value) => { + const answer = value.trim(); + if (answer) this.input.onSubmit(answer); + }; + } + + invalidate(): void { + this.editor.invalidate(); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.input.onSkip(); + return; + } + this.editor.handleInput(data); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + return [ + padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), + padLine(ansi.dim('Type another answer · Enter submit · Esc unanswered'), safeWidth), + ...this.editor.render(safeWidth).map((line) => padLine(line, safeWidth)), + padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), + ]; + } +} + export function modelPickerItems(currentModel: string, models: readonly string[] | undefined): SelectItem[] { const ids: string[] = []; const seen = new Set(); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index ec2c687daa..b7a6246e2b 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -25,6 +25,9 @@ import type { ModelChoice } from './connection-target.js'; import type { MakaSessionDriver, MakaSessionSwitchResult } from './session-driver.js'; import { createMakaPiTranscriptState, + activePermissionRequest, + activeUserQuestionRequest, + completePendingInteraction, applyShellRunViewUpdateToTranscript, replaceTranscriptWithStoredMessages, submitCompactToTranscript, @@ -51,6 +54,7 @@ import { import { MakaAutocompleteProvider, PickerOverlay, + UserQuestionTextOverlay, modelChoicePickerItems, modelPickerItems, permissionModePickerItems, @@ -111,6 +115,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let busy = false; let closed = false; let permissionInFlight = false; + let userQuestionInFlight = false; + let userQuestionOverlay: OverlayHandle | undefined; + let userQuestionProgress: { + requestId: string; + index: number; + answers: Array; + } | undefined; let turnRunning = false; let interruptRequested = false; let lastTurnEscapeAt = 0; @@ -344,7 +355,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { decision: 'allow' | 'deny', rememberForTurn = false, ): boolean => { - const request = state.pendingPermission; + const request = activePermissionRequest(state); if (!request || permissionInFlight) return false; permissionInFlight = true; // Keep the prompt visible until the driver accepts the response. If it @@ -359,14 +370,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // The turn may have ended (error/abort/complete) and cleared the pending // prompt while this response was in flight; only record success if the // request is still the active one. - if (state.pendingPermission?.requestId !== request.requestId) return; - state.pendingPermission = undefined; + if (activePermissionRequest(state)?.requestId !== request.requestId) return; + completePendingInteraction(state, request.requestId); state.entries.push({ kind: 'notice', level: 'info', text: `Permission ${decision}ed for ${request.toolName}`, }); requestRender(); + syncUserQuestionOverlay(); }) .catch((error) => { permissionInFlight = false; @@ -419,7 +431,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // A pending decision blocks the turn; ring an unfocused terminal once when // the prompt first appears (not on every render) so the user is not left // waiting on a prompt they cannot see. - if (state.pendingPermission) { + if (state.pendingInteraction) { if (!permissionAlerted) { permissionAlerted = true; attention.attentionNeeded(); @@ -428,6 +440,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { permissionAlerted = false; } shellRunElapsedTicker.sync(); + syncUserQuestionOverlay(); requestRender(); }, }).then((outcome) => { @@ -555,6 +568,104 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { margin: { bottom: BOTTOM_PICKER_MARGIN_ROWS }, }); + const closeUserQuestionOverlay = (): void => { + userQuestionOverlay?.hide(); + userQuestionOverlay = undefined; + }; + + const finishUserQuestion = (requestId: string, answers: Array): void => { + if (userQuestionInFlight) return; + const respond = input.driver.respondToUserQuestion; + if (!respond) { + reportError(new Error('User questions are unavailable on this driver.')); + return; + } + userQuestionInFlight = true; + closeUserQuestionOverlay(); + void respond.call(input.driver, { requestId, answers }) + .then(() => { + userQuestionInFlight = false; + if (activeUserQuestionRequest(state)?.requestId === requestId) { + completePendingInteraction(state, requestId); + } + userQuestionProgress = undefined; + syncUserQuestionOverlay(); + requestRender(); + }) + .catch((error) => { + userQuestionInFlight = false; + reportError(error); + syncUserQuestionOverlay(); + }); + }; + + const showUserQuestion = (): void => { + const request = activeUserQuestionRequest(state); + const progress = userQuestionProgress; + if (!request || !progress || progress.requestId !== request.requestId) return; + const question = request.questions[progress.index]; + if (!question) { + finishUserQuestion(request.requestId, progress.answers); + return; + } + closeUserQuestionOverlay(); + const items: SelectItem[] = [ + ...question.options.map((option, index) => ({ + value: `option:${index}`, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + { value: 'other', label: 'Other…', description: 'Type another answer.' }, + ]; + const list = new SelectList(items, 10, selectListTheme(), { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 48, + }); + const advance = (answer: string | null): void => { + progress.answers[progress.index] = answer; + progress.index += 1; + showUserQuestion(); + }; + list.onSelect = (item) => { + if (item.value === 'other') { + closeUserQuestionOverlay(); + userQuestionOverlay = showBottomPicker(new UserQuestionTextOverlay(tui, { + title: question.question, + rightLabel: `${progress.index + 1} / ${request.questions.length}`, + onSubmit: advance, + onSkip: () => advance(null), + })); + return; + } + const optionIndex = Number(item.value.slice('option:'.length)); + advance(question.options[optionIndex]?.label ?? null); + }; + list.onCancel = () => advance(null); + userQuestionOverlay = showBottomPicker(new PickerOverlay(list, { + title: question.question, + rightLabel: `${progress.index + 1} / ${request.questions.length}`, + hint: '↑↓ move · Enter select · Esc unanswered', + })); + }; + + const syncUserQuestionOverlay = (): void => { + const request = activeUserQuestionRequest(state); + if (!request) { + closeUserQuestionOverlay(); + userQuestionProgress = undefined; + return; + } + if (userQuestionInFlight) return; + if (userQuestionProgress?.requestId !== request.requestId) { + userQuestionProgress = { + requestId: request.requestId, + index: 0, + answers: Array.from({ length: request.questions.length }, () => null), + }; + showUserQuestion(); + } + }; + const showSelectPicker = ( title: string, rightLabel: string, @@ -979,6 +1090,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // press+release pair could count as a double Escape. We never act on // releases here; returning undefined lets the TUI apply its own filtering. if (isKeyRelease(data)) return undefined; + if ( + activeUserQuestionRequest(state) + && turnRunning + && matchesKey(data, Key.ctrl('c')) + && !isKeyRepeat(data) + ) { + if (interruptRequested) handleProcessExit(0); + else requestTurnInterrupt(); + return { consume: true }; + } if (tui.hasOverlay()) return undefined; if (matchesKey(data, Key.ctrl('c')) && isKeyRepeat(data)) return { consume: true }; if (!matchesKey(data, Key.ctrl('c'))) lastIdleCtrlCAt = 0; @@ -998,14 +1119,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return { consume: true }; } } - if (state.pendingPermission) { + const pendingPermission = activePermissionRequest(state); + if (pendingPermission) { if (matchesKey(data, 'y') || matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { respondToPendingPermission('allow', false); return { consume: true }; } if ( matchesKey(data, 'a') - && state.pendingPermission.rememberForTurnAllowed === true + && pendingPermission.rememberForTurnAllowed === true ) { respondToPendingPermission('allow', true); return { consume: true }; diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index a697c9fe66..11366f4560 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { realpath } from 'node:fs/promises'; import type { SessionEvent } from '@maka/core/events'; import type { PermissionMode, PermissionResponse } from '@maka/core/permission'; +import type { UserQuestionResponse } from '@maka/core/user-question'; import type { BranchFromTurnInput, CreateSessionInput, UserMessageInput } from '@maka/core/runtime-inputs'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -14,6 +15,7 @@ export interface MakaSessionRuntime { compactSession(sessionId: string, input?: { turnId?: string }): AsyncIterable; stopSession(sessionId: string, input?: { source?: 'stop_button' }): Promise; respondToPermission(sessionId: string, response: PermissionResponse): Promise; + respondToUserQuestion?(sessionId: string, response: UserQuestionResponse): Promise; setPermissionMode(sessionId: string, mode: PermissionMode): Promise; updateSession(sessionId: string, patch: { model?: string; llmConnectionSlug?: string; thinkingLevel?: ThinkingLevel | undefined; name?: string }): Promise; // Rewind reuses the runtime's branch primitives: a non-destructive copy of the @@ -58,6 +60,7 @@ export interface MakaSessionDriver { sendPrompt(prompt: string): AsyncIterable; compactSession(): AsyncIterable; respondToPermission(response: PermissionResponse): Promise; + respondToUserQuestion?(response: UserQuestionResponse): Promise; /** * Switch the active session's model, optionally rebinding it to another * connection at the same time (cross-provider `/model`). The next turn builds @@ -136,6 +139,12 @@ class RuntimeMakaSessionDriver implements MakaSessionDriver { await this.input.runtime.respondToPermission(this.sessionId, response); } + async respondToUserQuestion(response: UserQuestionResponse): Promise { + if (!this.sessionId) throw new Error('Cannot respond to a user question before a session starts.'); + if (!this.input.runtime.respondToUserQuestion) throw new Error('User questions are unavailable on this runtime.'); + await this.input.runtime.respondToUserQuestion(this.sessionId, response); + } + async setModel(model: string, connectionSlug?: string): Promise { // Only rebind the connection when a different one is asked for; a same-slug // /model is a plain model change and must not churn the backend needlessly. From 8b34eed734e81a2b71ea6c229a1a126e98017f5a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 14 Jul 2026 02:24:13 +0800 Subject: [PATCH 5/6] test(runtime): update turn lifecycle contract --- .../__tests__/tool-runtime-extraction-contract.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts index 3c05c9441e..c8e67344ff 100644 --- a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts @@ -42,8 +42,13 @@ describe('ToolRuntime extraction contract', () => { ); assert.match( backend, - /cleanupAfterTurn\(turnId: string\): void \{[\s\S]*?this\.toolRuntime\.resetTurnState\(\);[\s\S]*?\}/, - 'turn cleanup must reset ToolRuntime-owned per-turn state', + /this\.toolRuntime\.beginTurn\(turnId\);/, + 'turn start must establish ToolRuntime-owned per-turn state', + ); + assert.match( + backend, + /cleanupAfterTurn\(turnId: string\): void \{[\s\S]*?this\.toolRuntime\.endTurn\(turnId,[\s\S]*?\);[\s\S]*?\}/, + 'turn cleanup must settle and reset ToolRuntime-owned per-turn state', ); assert.doesNotMatch(backend, /private async writeSyntheticToolResult/); From 5475dae4cc331097f505d7db65fe2586cc9bff83 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 14 Jul 2026 02:30:17 +0800 Subject: [PATCH 6/6] fix(cli): expose user question stop action --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 2 ++ packages/cli/src/pi-tui-pickers.ts | 2 +- packages/cli/src/pi-tui-runner.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index d83006c741..089d72578a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -297,6 +297,7 @@ describe('Maka Pi TUI runner', () => { 'Choose an approach', 'Maka claude-sonnet-4-5 claude-subscription ask /repo', ); + assert.ok(plainTerminalOutput(terminal.screenOutput()).includes('Ctrl+C stop')); terminal.input('\r'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Keep the default')); @@ -306,6 +307,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\x1b[B'); terminal.input('\r'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Type another answer')); + assert.ok(plainTerminalOutput(terminal.screenOutput()).includes('Ctrl+C stop')); terminal.input('Use the existing seam'); terminal.input('\r'); diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 154ea5154b..152d3589d0 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -150,7 +150,7 @@ export class UserQuestionTextOverlay implements Component { const safeWidth = Math.max(1, width); return [ padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), - padLine(ansi.dim('Type another answer · Enter submit · Esc unanswered'), safeWidth), + padLine(ansi.dim('Type another answer · Enter submit · Esc unanswered · Ctrl+C stop'), safeWidth), ...this.editor.render(safeWidth).map((line) => padLine(line, safeWidth)), padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), ]; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index b7a6246e2b..6620b1e434 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -644,7 +644,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { userQuestionOverlay = showBottomPicker(new PickerOverlay(list, { title: question.question, rightLabel: `${progress.index + 1} / ${request.questions.length}`, - hint: '↑↓ move · Enter select · Esc unanswered', + hint: '↑↓ move · Enter select · Esc unanswered · Ctrl+C stop', })); };