Make confirm a real approval everywhere it lands, and stop two silent losses - #932
Conversation
`trimHistoryForModel` derived its cut points from `user` indices only and sliced from one of them, so anything ahead of the first user message — in practice the system prompt this package itself builds — disappeared the moment a conversation crossed the budget, with no error. A leading run of `system` messages is now held out of the turn scan and prepended to the result. Its size still counts against the budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
A confirm's requested schema is deliberately empty, so the elicitation message is the whole approval card. `withConfirmDetails` interpolated string values of `contentData` verbatim into `Label: value` lines joined by newlines, and `contentData` is an input port on `HumanInputTask` — so a value carrying a newline wrote further labelled lines no reader could tell from the real ones, or padded the card with blanks until the true detail scrolled out of view. Line breaks in an interpolated value or label are now escaped, so the card's line structure comes from the code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
`confirm` was a public interaction kind neither CLI connector implemented. Both fell through to their elicit form, which renders the DESCRIPTION of the action as editable fields, resolves `accept` on an ordinary submit, and has no `decline` at all — so a run asking permission got permission from anyone pressing Enter, and the operator's edits to that description landed on the task's output ports. The web console was worse: the run event carries `kind` and `data`, but the console's reducer kept neither, so it drew the confirm's labels as empty boxes to type into and never showed the values at all. `humanPromptModel` now decides the shape once for both renderers: an approval reads its values rather than editing them, offers approve and decline distinctly from cancel, and carries no content back. The reducer keeps `kind` and `data`. Both connectors join the conformance suite, answered through that model, so `roundtrip.confirm.decline` runs against the implementations that failed it. A new `roundtrip.confirm.noContent` pins the contract the adapters disagreed on — a confirm answers with the decision and nothing else — and MockHumanConnector holds it as the reference. The elicit form's missing decline is recorded as an expected failure rather than widened into here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
fe687ae to
58273d2
Compare
There was a problem hiding this comment.
🟡 Changes recommended
MockHumanConnector still allows content to survive on non-accepted or non-elicit responses, which contradicts the newly documented IHumanResponse.content contract and can mask adapter bugs in the conformance suite.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR tightens the “human interaction” contract across CLI + web console + MCP by making kind: "confirm" behave as a true approval (read-only details + approve/decline/cancel, and no returned content), while also fixing two silent data-loss/forgery issues in adjacent surfaces (chat history trimming and confirm-card rendering).
Changes:
- Introduces a shared, renderer-agnostic
humanPromptModeland wires both Ink + web console UIs to render/answerconfirmas an approval (not an editable form), with conformance coverage. - Fixes
trimHistoryForModelto preserve leadingsystemmessages when trimming by budget. - Prevents newline-based “approval card” forgery by escaping line breaks in MCP confirm-card details (labels and values).
File summaries
| File | Description |
|---|---|
| packages/util/src/human/HumanConnector.ts | Documents the tightened IHumanResponse.content contract (only accepted elicit may carry content). |
| packages/test/src/test/human/RunEventHumanConnector.conformance.test.ts | Adds conformance coverage for the web console connector (via reduced run-event view state). |
| packages/test/src/test/human/McpElicitationConnector.confirmCard.test.ts | Adds regression tests for newline/blank-line forgery in MCP confirm cards. |
| packages/test/src/test/human/InkHumanConnector.conformance.test.ts | Adds conformance coverage for the Ink connector. |
| packages/test/src/test/human/cliHumanSurface.ts | Test harness that answers through humanPromptModel (surface-constrained scripting). |
| packages/test/src/contract/human-connector/types.ts | Extends assertion id set for the new confirm “no content” rule. |
| packages/test/src/contract/human-connector/MockHumanConnector.ts | Updates reference connector behavior for confirm responses (but currently not fully aligned with the new content contract). |
| packages/test/src/contract/human-connector/assertions/roundtrip.ts | Adds roundtrip.confirm.noContent assertion. |
| packages/test/package.json | Adds @workglow/cli devDependency so contract tests can import connector implementations. |
| packages/mcp/src/tasks/McpElicitationConnector.ts | Escapes line breaks in confirm-card labels/values to prevent forged lines. |
| packages/ai/src/task/ChatHistory.ts | Preserves a leading system prefix when trimming to a character budget. |
| packages/ai/src/task/tests/ChatHistory.test.ts | Adds tests covering preservation of leading system messages across trimming. |
| examples/cli/src/web/client/views/HumanPrompt.tsx | Renders via humanPromptModel to distinguish approvals vs forms; adds decline action support for confirm. |
| examples/cli/src/web/client/state.ts | Ensures run-event reduction retains kind and data for human requests (needed for confirm details). |
| examples/cli/src/ui/model/humanPrompt.ts | New shared prompt-shaping model (approval/form/acknowledge) + one-line escaping for details. |
| examples/cli/src/ui/model/humanPrompt.test.ts | Unit tests for the shared prompt model. |
| examples/cli/src/ui/HumanInteractionHost.tsx | Adds HumanConfirmPanel with approve/decline/cancel key handling. |
| examples/cli/src/human.ts | New @workglow/cli/human subpath entrypoint exporting connectors + shared model + reducer utilities. |
| examples/cli/package.json | Adds ./human export and build step for the new entrypoint. |
| bun.lock | Updates workspace lockfile for the new @workglow/cli dependency edge. |
Review details
- Files reviewed: 19/20 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…cuments `shape()` stripped `content` for a confirm only, while `IHumanResponse.content` is documented as present only for an accepted elicit. A scripted decline or cancel could still hand data back, so the conformance suite could not fail an adapter that leaks a refused form onto `HumanInputTask`'s output ports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
#929 has since been squash-merged, so this now targets
maindirectly. The branch was rebuilt as its own commits on top of current main — the eight commits it previously carried from #929's branch are already in main as that squash, and were the whole of the merge conflict. Three findings against #929's work, each verified here before it was fixed, plus one from the review round below.1.
confirmwas a public kind no shipped connector implementedWhat was wrong.
HumanInteractionKindandHumanInputTask's config enum both gainedconfirm, but neither connector inexamples/clihandled it.HumanInteractionHost'sswitchfell through toHumanElicitPanel, which builds editable fields from every property (prepareSchemaFormFields=getAllFields) and resolves{ action: "accept", content: merged }on submit; Esc givescancel, and there was nodeclinepath at all. The web console was worse:RunEventHumanConnectordoes emitkindanddataon the run event, butreduceRunEventkept neither, soHumanPromptbuilt empty text boxes from the confirm'scontentSchema, never rendered the values, and offered only Send→accept / Cancel.What that allowed. A graph running
HumanInputTask({ kind: "confirm", ... })on a TTY showed "Input required" with the description of the action as prefilled editable fields; pressing Enter through them returnedaccept, andHumanInputTask.executespreadsresponse.contentonto its output ports — so the operator's edits to the description of the action became part of what the graph did with it. On the console the same confirm showed the message, two empty boxes, and no way to refuse.roundtrip.confirm.declineexists to catch exactly "accept without asking", and neither connector was registered with the suite, so it never ran against either.What changed.
humanPromptModel(examples/cli/src/ui/model/humanPrompt.ts) decides the shape once for both renderers — it lives in the directory whose existing guard test fails if anything there imports a renderer, so the Ink panel and the browser card cannot disagree. A confirm is an approval: its values are read, not edited; it offers approve and decline distinctly from cancel; and it carries no content back.HumanInteractionHostgained aHumanConfirmPanel(y/Enter approve,ndecline, Esc cancel),reduceRunEventnow keepskindanddata, and the console'sHumanPromptrenders the model.Both connectors are now registered with
runHumanConnectorConformance, answered through that model: the scripted answer is given when the drawn surface offers it, and otherwise the person is left with the surface's primary action. That is what makes the registration non-vacuous — a confirm drawn as a form has no decline to press, so the assertion fails rather than passing on an answer no rendering could have produced.Also pinned, per the review's open question about whether a confirm may return
contentat all: it may not.IHumanResponse.contentsays so, a newroundtrip.confirm.noContentassertion pushes a scriptedcontentalongside an accepted confirm and requires it not to come back, andMockHumanConnectorholds the contract as the reference implementation.McpElicitationConnectoralready did.2.
trimHistoryForModelsilently dropped a leading system messagepackages/ai/src/task/ChatHistory.tsderived cut points fromrole === "user"indices only and sliced from one of them, so anything ahead of the first user message went with the trim.ChatRoleincludes"system"and this package builds such lists itself (AiChatWithKbTask). A host keeping its system prompt ashistory[0]lost its instructions and guardrails, with no error, the first time a conversation crossed 120k chars — and every turn after. A leading run ofsystemmessages is now held out of the turn scan and prepended to the result; its size still counts against the budget, so the turns cut to fit around it.3. The MCP approval card could be forged by a newline
withConfirmDetailsJSON-escaped non-strings and interpolated strings verbatim intoLabel: valuelines joined by\n. A confirm'srequestedSchemais deliberately empty, so that string is the whole approval card, andHumanInputTask.contentDatais an input port — reachable from a model over MCP. One newline in a value wrote a second labelled line indistinguishable from a real one (Reaches: (nothing beyond running a model)under a trueReaches:naming an attacker host), or padded the card with blanks until the real detail was out of view. Line breaks in an interpolated value or label are now escaped, so the card's line structure comes from the code.4. The reference connector did not hold the contract this PR documents (review round)
Raised by a Copilot review on the diff above and confirmed against the code.
MockHumanConnector.shape()strippedcontentfor aconfirmonly, while item 1 documentsIHumanResponse.contentas present only for an acceptedelicit. A scripteddecline/canceltherefore handed data straight back, and nothing caught it —roundtrip.declineandroundtrip.cancelboth scriptcontent: undefined, so the gap was invisible to the suite. That matters because this connector is the yardstick adapters are measured against: a loose reference cannot fail an adapter that leaks a refused form ontoHumanInputTask's output ports, which is the same failure item 1 exists to close.shape()now keepscontentonly whenrequest.kind === "elicit" && response.action === "accept". The other half of the review's claim —accepton a non-elicit kind — was already safe by a different route:notify/displayreturn beforeshape()on the fast-resolve path. The tightened condition states that directly rather than depending on that early return. Nothing depended on the looser behaviour: the two content-bearing assertions (multiTurnFollowUp,concurrentIsolation) both useelicit+accept.The coverage is a unit test on the reference connector rather than a new conformance assertion, deliberately: neither CLI surface can decline an elicit at all — both already declare
roundtrip.declineas an expected failure — so a suite-level assertion would have added two more expected failures rather than testing anything new.Verified
Run in a worktree after
bun installandbun run use-source, and re-run in full after the rebuild onto main:bunx vitest run packages/ai/src/task/__tests__/ChatHistory.test.ts— the two new system-message tests failed on the unfixed code (expected [ 'user' ] to deeply equal [ 'system', 'user' ]), pass after. 12 passed.bunx vitest run packages/test/src/test/human/— 7 files, 109 passed, 3 expected fail, 4 skipped. The three MCP forgery tests failed on the unfixed code (4 detail lines where 2 were expected; 40 blank lines where 1 was expected). The three expected failures are unchanged from before item 4, so no adapter regressed against the tightened reference.roundtrip.confirm.declinefor both, plusroundtrip.confirm.detailsand both notify/display fast-resolve assertions for the console connector (the reducer droppedkindanddata).2 failed | 17 passed;19 passedwith the fix.bunx vitest run --project cli— 40 files, 294 passed, 1 skipped.bun run build:types— 43/43 successful.bun run lint— exit 0.bun run format-check— clean, 2478 files. Working tree clean after install (no lockfile drift).Not run here: the full
bun run testand the fullbun run build— both are long, and nothing outside the sections above imports what changed. The integration and end-to-end tiers were not run (they want databases and live keys).Deliberately left unfixed
roundtrip.declineas an expected failure with a comment saying why, rather than growing a third exit on a text form: unlike a confirm, an elicit's caller wanted a value and gets none either way. Recorded rather than papered over.RunEventHumanConnectorresolves an aborted send ascancelrather than rejecting. That is deliberate and documented on the class — a rejection would surface as a task failure rather than the cancellation it is — butabort.beforeSendstates the opposite, so it is declared an expected failure too. Turning it into a capability flag would widen the contract API beyond these findings.New surface
@workglow/cligains a./humansubpath (the two connectors, the prompt model, the console reducer) so the conformance suite can reach them without loading the command tree behindlib.ts, and@workglow/testdevDepends on@workglow/cli— the same direction as every other arrow into that package. Worth a maintainer's eye: this is a new public export and a new dependency edge, a design call rather than a bug fix.🤖 Generated with Claude Code
https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ