design phase 9 w14.1 claude elicitation - sinrimin/harness-nexus GitHub Wiki
Status: IMPLEMENTED 2026-09-17 (rig E2E results in ยง7) Research/ground truth:
research-phase-9-w14.1-claude-ground-truth.mdCompanion fix (merged first):fix/p9-w14.1-claude-todo-envโ the claude plan/todo lane revival viaCLAUDE_CODE_ENABLE_TODO_TOOLS=1.
claude-code's AskUserQuestion (and MCP-server elicitations mounted under
claude) are fully bridged by the ACP wrapper, but the portal never sees them:
our daemon's initialize sends clientCapabilities: {}, so the wrapper adds
AskUserQuestion to disallowedTools (the model never gets the tool) and
would not forward MCP elicitations either. Every other target has NO adapter-
side elicitation bridge (research ยง1), so this wave wires the client side
once and claude is the only producer โ deliberately not per-target gating.
Two stream events + one request/response pair, mirroring the permission lifecycle exactly:
// stream: daemon โ server โ browser (and into the daemon history ring, so a
// resync re-shows the card; the daemon-side pending map survives resync)
{ kind: 'elicitation_request', requestId, message โค2048,
fields: ElicitationField[] โค16, // MAY be empty โ see ยง3
toolCallId? }
{ kind: 'elicitation_resolved', requestId,
outcome: 'accepted' | 'declined' | 'cancelled' | 'timeout' }
// browser โ server โ daemon (verbatim forward both hops):
{ sessionId, requestId, action: 'accept' | 'decline' | 'cancel',
values?: Record<string, string | number | boolean | string[]> } // accept onlyElicitationField is OUR bounded rendering hint, extracted daemon-side โ
never the raw JSON schema on the wire:
{ name โค128, type: 'text'|'number'|'integer'|'boolean'|'enum'|'multi',
title? โค256, description? โค1024, placeholder? โค256, required?,
options?: { value โค1024, label? โค256, description? โค1024 }[] โค32 }- accept โ daemon answers the adapter
elicitation/createJSON-RPC request with{action:'accept', content: values ?? {}}(values keyed by field name, verbatim โ the wrapper folds them into the tool input). - decline โ
{action:'decline'}; cancel โ{action:'cancel'}.
-
agent-connection.ts: initialize now advertisesclientCapabilities: { elicitation: { form: {} } }(globally โ other adapters ignore it). Incomingelicitation/createrequests (top-level method, NOT thesession/requestenvelope) dispatch to a newsetElicitationHandler;respondElicitation(jsonrpcId, response)answers. Unhandled agent-side requests are still dropped as before. -
chat.ts:DaemonSession.elicitationsmap (requestId โ {jsonrpcId, timer}), mirroringpermissions. The handler mints a UUID requestId, arms a 75s backstop timer (cancel +elicitation_resolved timeoutโ the server watchdog is the primary), and emitselicitation_requestwithtakeElicitationView(params):-
messageclamped to 2048 (missing โ''). -
toolCallIdpassed through when a string (โค256). -
requestedSchema.properties(โค16 entries) reduced per property:oneOf/enumconsts โenumoptions (string entries, or objects'const+title/description);type:'array'+itemsconsts โmulti;type:'number'|'integer'|'boolean'|'string'โ the matching kind (options win overtypefor strings);required[]membership โrequired:true; title/description/placeholder clamped. - Structurally unusable properties (nested
object, no type + no options) are DROPPED. An EMPTY field list is valid and surfaces as a message-only card (decline/cancel available, no accept) โ the user still sees that the agent asked, and the agent still gets an answer.
-
- Teardown (
closeInternal) clears elicitation timers like permission's. -
DAEMON_VERSIONโ0.21.0-p9w14.1.
-
onStreamarms an elicitation watchdog perelicitation_requestโ same policy and same timeout value as permissions (CHAT_PERMISSION_TIMEOUT_MS): timeout โchat:elicitation.respond {action:'cancel'}to the daemon +elicitation_resolved timeoutto the channel. One config knob for both is deliberate (both are "the agent waits on a human" latencies). -
onElicitationRespond(ownerId, sessionId, requestId, action, values): owner gate โ clear watchdog โ forward verbatim to the daemon โ emitelicitation_resolved(accepted/declined/cancelled). Unknown ids โELICITATION_NOT_FOUND(404-style, mirroring permission's 404-ism). -
/appregisterschat:elicitation.respond(validated by the shared request schema, ack{accepted:true}/{error:code}). - Session teardown clears
elicitationTimers.
-
realtime.tshand-mirrors the new event/field types. -
fold.ts:elicitations: ElicitationCardState[]slice ({requestId, message, fields, toolCallId?, settled}), arms mirror the permission pair (request upserts by requestId, resolved setssettled). -
components/chat/elicitation-cards.tsxrenders unsettled cards inline next toPermissionCards(same warn-tone card chrome โ a pending question is a decision). Per field:enumโ radio rows (label + description),multiโ checkbox rows,booleanโ checkbox,number/integerโ number input,textโ text input (autoComplete="off",spellCheck={false}โ protocol-ish inputs). Answer submits when everyrequiredfield has a value; Decline/Cancel always available. Settled cards vanish (the answer lands in the following assistant message โ no settled-remnant line, unlike permissions whose choice has no other visible trace). - Strings in
i18n/strings/chat.ts(en/zh).
-
fixtures/acp-agent.mjs: prompt containingask-usersends a realelicitation/createrequest (enum + text + boolean + integer fields,question_0required), waits for the response, and echoeselicitation answered: <json>as the turn's message โ exercising the full request/response round trip. - shared: schema accept/reject cases (field types, value union, caps).
- cli:
takeElicitationViewunit cases (oneOf consts, string enum, arrayโ multi, required, clamps, drops) + fixture round-trip integration (event โchat:elicitation.respondaccept โ echoed answer). - server: relay + respond routing + watchdog timeout (mirrors the permission tests).
Browser E2E on a fresh claude-code channel (/root, gateway model
deepseek-v4-flash, wrapper 0.78.0 โ SDK-bundled CLI 2.1.270):
-
Ask User โ โ "Use the AskUserQuestion tool to ask me: which color do
you prefer, red or blue?" โ the question card rendered inline (running
tool row + Red/Blue radios with descriptions + the
Othercustom text field + ๅ็ญ/ๆ็ป/ๅๆถ). Selecting Red + ๅ็ญ settled the card (it VANISHES per ยง5) and the turn continued with the model acknowledging "You prefer red. ๐" (turn tail 41.4s ยท ctx 22k/200k) โ the values round-tripped verbatim into the tool input. - Plan lane โ (companion env fix, same deploy) โ "Use TaskCreate to create two todos alpha/beta, mark alpha completed" โ the model replied DONE and the TodoPanel lit up above the composer: ๅทฒๅฎๆ 1 ยท ๅพ ๅ 1 (1/2), expanded list showing alpha + beta. The W14 claude column is live.
- The MCP-elicitation path was not exercised (no MCP server mounted under the chat adapter on the rig); codex / opencode / dsh unchanged โ no elicitation producers (research ยง1).
- Screenshot:
/tmp/w14.1-rig-e2e.png(ephemeral).
-
url-mode elicitation is NOT advertised (
elicitation.urlabsent): the wrapper would then sendelicitation/create {mode:'url'}+ asession/complete_elicitationnotification โ an OAuth-jump class of UI we do not want in the portal. If ever needed, advertise + render a link card. - codex / opencode / dsh ask-user: blocked adapter-side (research ยง1) โ
revisit when codex-acp implements
EventMsg::RequestUserInputor opencode adds a bridge. - Numeric
minimum/maximum, stringformat/patternhints: fields render as plain inputs; validation stays adapter-side (it owns the schema). - Nested-object properties: dropped by the reducer (ยง3) โ the message-only card is the honest fallback.