mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
527 lines
23 KiB
TypeScript
527 lines
23 KiB
TypeScript
import type { FixtureCase } from '../fixtureTypes'
|
|
import { T0, wireMessage } from './support'
|
|
|
|
const localQuestionInput = {
|
|
questions: [{ question: 'Which database?', header: 'Database', multiSelect: false, options: [
|
|
{ label: 'SQLite', description: 'Local file' }, { label: 'Postgres', description: 'Server' }
|
|
] }]
|
|
}
|
|
const localQuestionMessages = [
|
|
wireMessage({ id: 'local-user', seq: 1, createdAt: T0, content: {
|
|
role: 'user', content: { type: 'text', text: 'Choose a database.' }
|
|
} }),
|
|
wireMessage({ id: 'local-tool-call', seq: 2, createdAt: T0 + 1_000, content: {
|
|
role: 'agent', content: { type: 'output', data: {
|
|
type: 'assistant', uuid: 'local-tool-call', parentUuid: null,
|
|
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_local_question', name: 'AskUserQuestion', input: localQuestionInput }] }
|
|
} }
|
|
} })
|
|
]
|
|
const localRequest = { tool: 'AskUserQuestion', toolCallId: 'toolu_local_question', arguments: localQuestionInput, createdAt: T0 + 1_100 }
|
|
|
|
// Shared Codex provides a server request, not a transcript tool_use. These
|
|
// messages are the lifecycle persisted by SharedCodexPermissions itself.
|
|
const sharedQuestionInput = { threadId: 'thread', turnId: 'turn', itemId: 'call', questions: [{ id: 'choice', header: 'Choose', question: 'Which?', options: [
|
|
{ label: 'A', description: 'A' }, { label: 'B', description: 'B' }
|
|
] }] }
|
|
const sharedRequest = { tool: 'request_user_input', toolCallId: 'call', arguments: sharedQuestionInput, createdAt: T0 + 1_100 }
|
|
const sharedQuestionMessages = [
|
|
wireMessage({ id: 'shared-user', seq: 1, createdAt: T0, content: {
|
|
role: 'user', content: { type: 'text', text: 'Choose an option.' }
|
|
} }),
|
|
wireMessage({ id: 'shared-question', seq: 2, createdAt: T0 + 1_000, content: {
|
|
role: 'agent', content: { type: 'codex', data: {
|
|
type: 'tool-call', name: 'request_user_input', callId: 'call', input: sharedQuestionInput,
|
|
id: 'codex:thread:question:call:start'
|
|
} }
|
|
} })
|
|
]
|
|
const sharedQuestionResult = (status: 'resolved' | 'canceled') => wireMessage({
|
|
id: `shared-${status}`, seq: 3, createdAt: T0 + 5_000, content: {
|
|
role: 'agent', content: { type: 'codex', data: {
|
|
type: 'tool-call-result', callId: 'call', output: { status }, is_error: false,
|
|
id: `codex:thread:question:call:${status}`
|
|
} }
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Permission requests are NOT messages: they live in session.agentState
|
|
* (requests → completedRequests). A pending request whose tool_use message is
|
|
* not in the loaded window must still be answerable, so the reducer
|
|
* synthesizes a pending tool-call block for it — but only when the request is
|
|
* pending, has no tool call/result in the transcript, and is not older than
|
|
* the oldest loaded message (web/src/chat/reducer.ts).
|
|
*/
|
|
export const permissionCases: FixtureCase[] = [
|
|
{
|
|
name: 'permission-shared-pending',
|
|
description: 'requestUserInput without a native transcript item: CLI persists one question, permission state attaches without duplicating it.',
|
|
messages: sharedQuestionMessages,
|
|
agentState: { requests: { 'runtime:thread:number:1': sharedRequest } }
|
|
},
|
|
{
|
|
name: 'permission-shared-resolved',
|
|
description: 'Codex shared request resolved elsewhere: withdraw input, preserve neutral resolved status, never invent the winner or answers.',
|
|
messages: [...sharedQuestionMessages, sharedQuestionResult('resolved')],
|
|
agentState: { requests: {}, completedRequests: {
|
|
'runtime:thread:number:1': { ...sharedRequest, status: 'resolved', completedAt: T0 + 5_000 }
|
|
} }
|
|
},
|
|
{
|
|
name: 'permission-shared-canceled',
|
|
description: 'Disconnect withdraws a request-only shared question without claiming it was answered or losing its history.',
|
|
messages: [...sharedQuestionMessages, sharedQuestionResult('canceled')],
|
|
agentState: { requests: {}, completedRequests: {
|
|
'runtime:thread:number:1': { ...sharedRequest, status: 'canceled', completedAt: T0 + 5_000 }
|
|
} }
|
|
},
|
|
{
|
|
name: 'permission-local-question-pending',
|
|
description: 'A local permission has an independent request ID: attach to the existing native toolCallId, but submit answers using permission.id. Do not synthesize a duplicate card.',
|
|
messages: localQuestionMessages,
|
|
agentState: { controlledByUser: true, requests: { 'claude-local:reply-1': localRequest } }
|
|
},
|
|
{
|
|
name: 'permission-local-question-synthesized',
|
|
description: 'Before the native tool_use arrives, synthesize one local question card keyed by toolCallId, with the independent reply ID preserved.',
|
|
messages: localQuestionMessages.slice(0, 1),
|
|
agentState: { controlledByUser: true, requests: { 'claude-local:reply-1': localRequest } }
|
|
},
|
|
{
|
|
name: 'permission-local-question-completed',
|
|
description: 'Native confirmation completes the same local question card with actual answers; permission.id remains the original reply ID, not the tool_use ID.',
|
|
messages: [...localQuestionMessages, wireMessage({ id: 'local-result', seq: 3, createdAt: T0 + 5_000, content: {
|
|
role: 'agent', content: { type: 'output', data: {
|
|
type: 'user', uuid: 'local-result', parentUuid: 'local-tool-call',
|
|
toolUseResult: { ...localQuestionInput, answers: { 'Which database?': 'Postgres' } },
|
|
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_local_question', content: 'The user answered: "Which database?"="Postgres".' }] }
|
|
} }
|
|
} })],
|
|
agentState: { controlledByUser: true, requests: {}, completedRequests: {
|
|
'claude-local:reply-1': { ...localRequest, status: 'approved', completedAt: T0 + 4_900, answers: { '0': ['Postgres'] } }
|
|
} }
|
|
},
|
|
{
|
|
name: 'permission-synthesized-pending',
|
|
description: 'A pending agentState request with no matching tool_use in the (older) message window synthesizes a pending tool-call block: state pending, permission.status pending, input from request.arguments.',
|
|
messages: [
|
|
wireMessage({
|
|
id: 'msg-user-121',
|
|
seq: 1,
|
|
createdAt: T0,
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Install the missing dependency.' }
|
|
}
|
|
})
|
|
],
|
|
agentState: {
|
|
requests: {
|
|
'req-01J5XKQ8TZ3M': {
|
|
tool: 'Bash',
|
|
arguments: { command: 'bun add zod', description: 'Install zod' },
|
|
createdAt: T0 + 6_000
|
|
}
|
|
},
|
|
completedRequests: {}
|
|
}
|
|
},
|
|
{
|
|
name: 'permission-approved-for-session',
|
|
description: 'Completed request approved with decision approved_for_session + mode + allowTools: the permission (from agentState.completedRequests, keyed by the tool_use id) attaches to the tool-call block; wire key allowTools projects as permission.allowedTools; the paired tool_result completes the tool.',
|
|
messages: [
|
|
wireMessage({
|
|
id: 'msg-user-401',
|
|
seq: 1,
|
|
createdAt: T0,
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Install zod and use it for the config schema.' }
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-402',
|
|
seq: 2,
|
|
createdAt: T0 + 2_000,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: '2b0c1d3e-7d9e-4faa-8c1b-c4d5e6f7a402',
|
|
parentUuid: null,
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'claude-opus-4-1-20250805',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'toolu_01PermBash0001',
|
|
name: 'Bash',
|
|
input: { command: 'bun add zod', description: 'Install zod' }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-403',
|
|
seq: 3,
|
|
createdAt: T0 + 14_000,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
uuid: '3c1d2e4f-8eaf-4abb-9d2c-d5e6f7a8b403',
|
|
parentUuid: '2b0c1d3e-7d9e-4faa-8c1b-c4d5e6f7a402',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu_01PermBash0001',
|
|
content: 'installed zod@4.2.1',
|
|
is_error: false
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
],
|
|
agentState: {
|
|
requests: {},
|
|
completedRequests: {
|
|
toolu_01PermBash0001: {
|
|
tool: 'Bash',
|
|
arguments: { command: 'bun add zod', description: 'Install zod' },
|
|
createdAt: T0 + 2_200,
|
|
completedAt: T0 + 6_500,
|
|
status: 'approved',
|
|
mode: 'acceptEdits',
|
|
decision: 'approved_for_session',
|
|
allowTools: ['Bash']
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: 'permission-denied-with-reason',
|
|
description: 'Completed request denied with a reason and no tool_result: the tool-call block is created from the tool_use with the denied permission attached, so its state maps to error and permission carries {status: denied, decision, reason}.',
|
|
messages: [
|
|
wireMessage({
|
|
id: 'msg-user-411',
|
|
seq: 1,
|
|
createdAt: T0,
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Clean up the workspace.' }
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-412',
|
|
seq: 2,
|
|
createdAt: T0 + 2_400,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: '4d2e3f5a-9fb0-4bcc-8e3d-e6f7a8b9c412',
|
|
parentUuid: null,
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'claude-opus-4-1-20250805',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'toolu_01PermDeny0001',
|
|
name: 'Bash',
|
|
input: { command: 'rm -rf /tmp/hapi-cache', description: 'Delete the cache directory' }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
],
|
|
agentState: {
|
|
requests: {},
|
|
completedRequests: {
|
|
toolu_01PermDeny0001: {
|
|
tool: 'Bash',
|
|
arguments: { command: 'rm -rf /tmp/hapi-cache', description: 'Delete the cache directory' },
|
|
createdAt: T0 + 2_600,
|
|
completedAt: T0 + 9_100,
|
|
status: 'denied',
|
|
decision: 'denied',
|
|
reason: 'Do not delete the cache — inspect it instead.'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: 'permission-canceled',
|
|
description: 'Completed request canceled (request withdrawn/aborted before an answer): the tool-call block created from the tool_use maps to state error with permission {status: canceled} and no mode/decision/reason.',
|
|
messages: [
|
|
wireMessage({
|
|
id: 'msg-user-421',
|
|
seq: 1,
|
|
createdAt: T0,
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Write the migration file.' }
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-422',
|
|
seq: 2,
|
|
createdAt: T0 + 1_900,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: '5e3f4a6b-0ac1-4cdd-9f4e-f7a8b9c0d422',
|
|
parentUuid: null,
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'claude-opus-4-1-20250805',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'toolu_01PermCancel001',
|
|
name: 'Write',
|
|
input: { file_path: '/repo/hub/migrations/0007_add_index.sql', content: 'CREATE INDEX idx_messages_seq ON messages(seq);' }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
],
|
|
agentState: {
|
|
requests: {},
|
|
completedRequests: {
|
|
toolu_01PermCancel001: {
|
|
tool: 'Write',
|
|
arguments: { file_path: '/repo/hub/migrations/0007_add_index.sql', content: 'CREATE INDEX idx_messages_seq ON messages(seq);' },
|
|
createdAt: T0 + 2_100,
|
|
completedAt: T0 + 4_800,
|
|
status: 'canceled'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: 'permission-ask-user-question-answered',
|
|
description: 'AskUserQuestion completed with FLAT answers (Record<question-index, string[]>): the completed request attaches to the tool-call; permission.answers keeps the flat map verbatim and the tool_result completes the interactive card.',
|
|
messages: [
|
|
wireMessage({
|
|
id: 'msg-user-431',
|
|
seq: 1,
|
|
createdAt: T0,
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Set up persistence for the prototype.' }
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-432',
|
|
seq: 2,
|
|
createdAt: T0 + 2_300,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: '6f4a5b7c-1bd2-4dee-8a5f-a8b9c0d1e432',
|
|
parentUuid: null,
|
|
message: {
|
|
role: 'assistant',
|
|
model: 'claude-opus-4-1-20250805',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'toolu_01AskDbChoice01',
|
|
name: 'AskUserQuestion',
|
|
input: {
|
|
questions: [
|
|
{
|
|
question: 'Which database should the prototype use?',
|
|
header: 'Database',
|
|
options: [
|
|
{ label: 'SQLite', description: 'Zero-config local file' },
|
|
{ label: 'Postgres', description: 'Matches production' }
|
|
],
|
|
multiSelect: false
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-433',
|
|
seq: 3,
|
|
createdAt: T0 + 21_000,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
uuid: '7a5b6c8d-2ce3-4eff-9b6a-b9c0d1e2f433',
|
|
parentUuid: '6f4a5b7c-1bd2-4dee-8a5f-a8b9c0d1e432',
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu_01AskDbChoice01',
|
|
content: 'User selected "SQLite" for "Which database should the prototype use?"',
|
|
is_error: false
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
],
|
|
agentState: {
|
|
requests: {},
|
|
completedRequests: {
|
|
toolu_01AskDbChoice01: {
|
|
tool: 'AskUserQuestion',
|
|
arguments: {
|
|
questions: [
|
|
{
|
|
question: 'Which database should the prototype use?',
|
|
header: 'Database',
|
|
options: [
|
|
{ label: 'SQLite', description: 'Zero-config local file' },
|
|
{ label: 'Postgres', description: 'Matches production' }
|
|
],
|
|
multiSelect: false
|
|
}
|
|
]
|
|
},
|
|
createdAt: T0 + 2_500,
|
|
completedAt: T0 + 20_400,
|
|
status: 'approved',
|
|
decision: 'approved',
|
|
answers: { '0': ['SQLite'] }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: 'permission-request-user-input-answered',
|
|
description: 'request_user_input (Pi, codex tool family) completed with NESTED answers (Record<questionId, {answers: string[]}>): the completed request attaches by callId; permission.answers keeps the nested map verbatim and the tool-call-result completes the card.',
|
|
messages: [
|
|
wireMessage({
|
|
id: 'msg-user-441',
|
|
seq: 1,
|
|
createdAt: T0,
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Deploy the latest build.' }
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-442',
|
|
seq: 2,
|
|
createdAt: T0 + 1_700,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'codex',
|
|
data: {
|
|
type: 'tool-call',
|
|
callId: 'rui-01J9ZC4QD2',
|
|
name: 'request_user_input',
|
|
input: {
|
|
questions: [
|
|
{
|
|
id: 'deploy_target',
|
|
question: 'Which environment should I deploy to?',
|
|
required: true,
|
|
multiple: false,
|
|
options: [
|
|
{ label: 'staging', description: 'Safe to test' },
|
|
{ label: 'production', description: 'Live traffic' }
|
|
]
|
|
}
|
|
]
|
|
},
|
|
id: '8b6c7d9e-3df4-4a0b-8c7b-c0d1e2f3a442'
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
wireMessage({
|
|
id: 'msg-agent-443',
|
|
seq: 3,
|
|
createdAt: T0 + 16_800,
|
|
content: {
|
|
role: 'agent',
|
|
content: {
|
|
type: 'codex',
|
|
data: {
|
|
type: 'tool-call-result',
|
|
callId: 'rui-01J9ZC4QD2',
|
|
output: { answers: { deploy_target: { answers: ['staging'] } } },
|
|
is_error: false,
|
|
id: '9c7d8eaf-4ea5-4b1c-9d8c-d1e2f3a4b443'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
],
|
|
agentState: {
|
|
requests: {},
|
|
completedRequests: {
|
|
'rui-01J9ZC4QD2': {
|
|
tool: 'request_user_input',
|
|
arguments: {
|
|
questions: [
|
|
{
|
|
id: 'deploy_target',
|
|
question: 'Which environment should I deploy to?',
|
|
required: true,
|
|
multiple: false,
|
|
options: [
|
|
{ label: 'staging', description: 'Safe to test' },
|
|
{ label: 'production', description: 'Live traffic' }
|
|
]
|
|
}
|
|
]
|
|
},
|
|
createdAt: T0 + 1_900,
|
|
completedAt: T0 + 16_200,
|
|
status: 'approved',
|
|
decision: 'approved',
|
|
answers: { deploy_target: { answers: ['staging'] } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|