feat(mcp): background MCP tasks → task_notification - #2
Open
HeavenllyDemon wants to merge 5 commits into
Open
Conversation
Third sibling of bg-registry (bash) and bg-agent-registry (subagents), with the same lifecycle-only, never-throws, takeForNotification-exactly-once contract. First terminal state wins, so an abort racing the stream's own settlement cannot be rewritten by a straggling result. The test server is built on the SDK's own server half, so the task test story costs no new dependency. Getting it to actually produce a task took three attempts, and the fixture documents all three requirements because each fails differently — most dangerously the missing server capability, which makes the call run synchronously with no error at all. Verified: taskCreated -> taskStatus(working) -> taskStatus(completed) -> result.
The SDK decides per call whether a tool runs as a task: it augments the request only when the tool declares execution.taskSupport AND the server advertised a tasks.requests.tools.call capability. So one path serves both arms, non-task servers stay byte-identical, and Norma never touches a server-authored schema. The task arm returns at taskCreated so the turn is not held open, then drains the rest in the background to settle the handle. A FAILING task arrives as the stream's `error` message rather than a `result` with isError:true — verified live — so once a task exists the error arm settles ok:false instead of throwing. Before a task is created there is nothing to settle later, so that arm still throws, matching callTool/callToolContent. callToolContent's inline block mapper is now the shared toBlocks helper.
A task-capable tool now returns a started-notice immediately instead of holding the turn open, and settles later through onTaskSettled. A non-task server is untouched: the SDK never augments its request, so it takes the sync arm and every pre-existing MCP test passes unmodified. ctx.signal aborts BOTH sides — cancelTask upstream so the server stops working, plus a "cancelled" terminal state locally. The registry's first-terminal-state- wins rule settles the genuine race between that abort and the stream's own settlement, so the notification stays honest instead of a straggling result rewriting it. An image settling after its turn cannot be attached (ctx.attachImage belonged to a turn that has ended), so it degrades to a labelled placeholder rather than silently vanishing. 38 pass across the MCP suite, 56 across everything MCP-touching.
Sibling of notifyBgCompletion, with the same exactly-once claim and the same wake discipline (defer while a turn runs, else start one). Pure addition — engine.ts gains 36 lines and deletes none, so every existing bg path is byte-identical. The result is sanitized on the same passes, and for a stronger reason: it is authored by a third-party MCP SERVER, not a subagent, and lands in durable history replayed into every later turn. Both ends of the tag are escaped here, not just the closing one: escaping only `</task-notification>` still lets a payload emit a bare opening tag that reads as a nested block. notifyBgCompletion escapes only the closing tag; the same hardening would suit it, but changing that path is out of scope. The injection test pins CONTAINMENT rather than tag stripping — an MCP server returning markup is legitimate, so the guarantee is that hostile content cannot end the block early or fake a nested one, and stays inert inside <result>. setup() gains an optional `mcp` so a test can wire EngineConfig.mcp; default undefined leaves all 101 pre-existing engine-spawn tests byte-identical.
Same later-assigned-closure shape as dispatchChildren and engine?.transcriptPathFor: McpManager is constructed long before the engine exists, so the callback is assigned after construction rather than passed in. The closure only ever runs when a real task settles. End to end now: a task-capable tool returns a started-notice, the turn continues, and completion arrives as a task_notification that wakes the session.
HeavenllyDemon
added a commit
that referenced
this pull request
Aug 19, 2026
…ents (checkpoint 7) - Correct the transport-cost attribution: the scroll-storm test's onTile callback only measures base64.utf8.count and never calls Data(base64Encoded:), so the ~19ms/tile decode cost is ADDITIVE on top of the measured 25.97/36.81ms warm/cold numbers, not already included in them. Folded back in, warm lands at the sustained-scroll demand estimate rather than comfortably clearing it; verdict revised to "clears with modest headroom" with a T6 recommendation to pipeline decode off the wire-receive path. - Update parseInvalidateTiles/parseModifiedStatus doc comments (OfficeWire.swift) with Task 4's real re-judgment: the part-defaulting leniency stays unjudged (zero real INVALIDATE_TILES firings observed against a view-only document), the ModifiedStatus non-"true" leniency is confirmed a safe fail-closed default with its mainline shape verified live but the leniency itself still unexercised by real data. - Fix OfficeWireCodecTests.swift's now-stale claim that its parser table is "the ONLY exercise of this parsing logic anywhere" -- true only for parseInvalidateTiles now that the live probe cross-checks parseModifiedStatus against a real firing. - Cross-reference the Debt #2 pin split from both tests, not just the new one: testGateXlsxRawTileHashMatchesTheGateTablePin now names itself the vendor-integrity half and points at its product-path sibling. - Disclose the stderr capture mechanism's line-based truncation of multi-line LOK callback payloads (observed live: a bare "{" fragment) so it isn't mistaken for a real, complete payload shape. No production behavior changes -- doc comments and test-comment honesty only, per advisor review before this task's report was finalized.
HeavenllyDemon
added a commit
that referenced
this pull request
Aug 19, 2026
Fix round 1's first pass covered trap #1 (indexRange overflow) and trap #2's non-trapping "huge but representable, exceeds the cap" variant over the live helper, but not trap #2's own genuine multiplication-overflow input -- caught on advisor review of the report before sending it back. testOverflowingViewportAtTheFinestValidZoomIsRefusedAndTheHelperSurvives drives it: zoomPPT at TileMath.maxZoomPPT (span 5 twips/tile), a length just under Int64.max/2 (indexRange itself stays representable, ~4.6e18 < Int64.max), producing a per-axis tile count around 9.2e17 whose SQUARE overflows Int64 inside estimatedTileCount's own multiplication -- refused as viewportTooLarge, helper survives a ping afterward. Same shape as TileMathTests' pure testEstimatedTileCountAllFourOutcomes case (d), now also proven live. All three trap inputs are now each driven over the real wire, per the review's original instruction. Targeted re-run (TileMath/TileCache/codec + the live classes): 92 tests, 0 failures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1 — base is
mcp-sdk-migration, notmain, so this diff shows only the tasks work. #1 must merge first.A task-capable MCP tool now hands the turn back immediately and reports completion through the same
task_notificationpath that backgrounded bash and detached subagents already use.Server-directed, by design
Norma adds no opt-in surface and never touches a server-authored schema.
client.experimental.tasks.callToolStreamdecides per call:If a tool wants to run in the background, that's the server's declaration to make. A non-task server takes the synchronous arm and is byte-identical to #1 — which is why every pre-existing MCP test passes unmodified.
What's here
mcp/task-registry.ts(new)bg-registryandbg-agent-registry. Lifecycle only, never throws,takeForNotificationexactly-once.mcp/client.tscallToolTaskconsumes the stream; returns attaskCreatedso the turn isn't held open, drains the rest in the background.cancelTaskpassthrough.mcp/manager.tsonTaskSettled, sharedrenderBlocks.engine.tsnotifyMcpTaskCompletion— +36 lines, 0 deletions, so every existing bg path is byte-identical.daemon.tsdispatchChildrenalready uses.test/agent/mcp/fake-task-server.ts(new)Security: server output lands in durable history
A task result is authored by a third-party MCP server and is persisted into session history that replays into every later turn. It goes through the same sanitisation
notifyBgCompletionapplies to subagent output, plus one step further: both ends of the tag are escaped, not just the closing one — escaping only</task-notification>still lets a payload emit a bare opening tag that reads as a nested block.The injection test pins containment, not tag stripping: an MCP server returning markup is legitimate, so the guarantee is that hostile content cannot end the block early or fake a nested one, and stays inert inside
<result>. (notifyBgCompletionescapes only the closing tag; the same hardening would suit it, but changing that path is out of scope here.)Races handled
ctx.signalcancels both sides —cancelTaskupstream so the server stops working, plus a localcancelledstate.onTaskSettled;takeForNotificationis the single-consumer claim, so only one notification is ever appended. Pinned by test.ctx.attachImagebelonged to a turn that has ended, so it degrades to a labelled placeholder instead of silently vanishing.Getting a task to actually happen took three attempts
Recorded in the fixture, because each condition fails differently and one fails silently:
execution: { taskSupport: "required" };tasks.requests.tools.callcapability — without itisToolTaskreturns false early and the call runs synchronously with no error at all;taskStorein its ProtocolOptions — without it the SDK throws before reaching the handler, surfacing as a misleadingInvalid task creation result: task undefinedon the client.Verified sequence:
taskCreated → taskStatus(working) → taskStatus(completed) → result. A failing task arrives as the stream'serrormessage, not aresultwithisError: true— so the error arm settles rather than throws once a task exists.Verification
mktemp, reproduces on pristine4d981b51)@norma/protocol/@norma/plugin-sdkengine-spawn.test.tssetup({ mcp })option defaults to undefined)tsc --noEmitapprovals.test.ts, none inagent/mcpverify:workflowNot in scope
input_requiredinteractive fulfilment — treated as terminal.QuestionBrokeris the natural future wiring; Norma consumes no MCP elicitation today.bg-agent-registry.listTasks(cursor?)is the SDK primitive a future pass would use, and it's paginated.task_get/task_list— those are Norma's to-do list, a different concept that shares the word.