fix: send_message_to_parent fails because child cannot learn its own child_session_id #333

Closed
opened 2026-07-14 09:04:29 +02:00 by dries · 2 comments
Owner

Symptom

A child/subagent session calling send_message_to_parent fails. The last failing call was:

send_message_to_parent [child_session_id=current, message=#325 done — ...]

The child passed the literal placeholder child_session_id=current. That string is looked up verbatim by GetChildSession("current") in internal/mcp/tools_comm.go:116 → returns sql.ErrNoRows → the tool returns child session not found: current. The message never reaches the parent.

Root cause

There is no way for a child agent to discover its own child_session_id.

  • send_message_to_parent requires a real child_session_id that exists in state.db's child_sessions table (internal/mcp/tools_comm.go:106-136).
  • That ID is the OpenCode session ID (childID = resp.ID in internal/mcp/launcher.go:171).
  • But the composed prompt sent to the child (internal/mcp/composer.go:168-175) tells it to "call send_message_to_parent when done" without telling it what child_session_id to pass.
  • get_current_session_id returns the most-recent OpenCode session ID, not framed as the child's own id, and the skill (.opencode/skills/ocman-session-splitting/SKILL.md) never instructs the child to resolve it.

So the agent guesses "current" and the call fails.

Fix

Inject the child's own child_session_id into the composed prompt. The prompt is composed before the session exists, but launchWithPort knows childID (== OpenCode session id) before it sends the first message (internal/mcp/launcher.go:171 → send at :189-196). Append a line to the composed prompt after childID is known, e.g.:

When reporting back, call send_message_to_parent with child_session_id=<childID>.

Update the "Reporting Back" section of the composed prompt to state the concrete id.

Also fix (drive-by)

internal/mcp/composer.go:168 and :175 emit two byte-identical "## Reporting Back" paragraphs. Delete the duplicate.

Verification

  • Add a launcher test asserting the sent prompt contains the child's session id (send_message_to_parent ... child_session_id=<childID>).
  • Add a composer test asserting the "Reporting Back" section appears exactly once.
  • Existing TestSendMessageToParent_DeliversToParent (internal/mcp/tools_test.go:930) still passes.
## Symptom A child/subagent session calling `send_message_to_parent` fails. The last failing call was: ``` send_message_to_parent [child_session_id=current, message=#325 done — ...] ``` The child passed the literal placeholder `child_session_id=current`. That string is looked up verbatim by `GetChildSession("current")` in `internal/mcp/tools_comm.go:116` → returns `sql.ErrNoRows` → the tool returns `child session not found: current`. The message never reaches the parent. ## Root cause There is no way for a child agent to discover its own `child_session_id`. - `send_message_to_parent` requires a real `child_session_id` that exists in `state.db`'s `child_sessions` table (`internal/mcp/tools_comm.go:106-136`). - That ID **is** the OpenCode session ID (`childID = resp.ID` in `internal/mcp/launcher.go:171`). - But the composed prompt sent to the child (`internal/mcp/composer.go:168-175`) tells it to "call send_message_to_parent when done" **without telling it what child_session_id to pass**. - `get_current_session_id` returns the most-recent OpenCode session ID, not framed as the child's own id, and the skill (`.opencode/skills/ocman-session-splitting/SKILL.md`) never instructs the child to resolve it. So the agent guesses `"current"` and the call fails. ## Fix Inject the child's own `child_session_id` into the composed prompt. The prompt is composed *before* the session exists, but `launchWithPort` knows `childID` (== OpenCode session id) before it sends the first message (`internal/mcp/launcher.go:171` → send at `:189-196`). Append a line to the composed prompt after `childID` is known, e.g.: > When reporting back, call `send_message_to_parent` with `child_session_id=<childID>`. Update the "Reporting Back" section of the composed prompt to state the concrete id. ### Also fix (drive-by) `internal/mcp/composer.go:168` and `:175` emit two byte-identical "## Reporting Back" paragraphs. Delete the duplicate. ## Verification - Add a launcher test asserting the sent prompt contains the child's session id (`send_message_to_parent ... child_session_id=<childID>`). - Add a composer test asserting the "Reporting Back" section appears exactly once. - Existing `TestSendMessageToParent_DeliversToParent` (`internal/mcp/tools_test.go:930`) still passes.
Author
Owner

Research: can the server identify the caller without the client passing an id?

Investigated whether MCP _meta, HTTP headers, or an MCP-session-id → child-session map could let send_message_to_parent know its own child_session_id without the child passing it. All rejected. The prompt-injection fix (inject childID into the composed prompt) stands as the only self-contained solution.

Rejected: _meta / HTTP headers / HTTPContextFunc

The server can read these — mcp-go v0.43.2 exposes WithHTTPContextFunc (streamable_http.go:113) and always puts r.Header in handler context (:393). But they only help if the client stamps its session identity, and OpenCode doesn't.

Decompiled from the OpenCode binary (1.17.15), the tool-call site is:

await X.callTool({ name: Q.name, arguments: W || {} }, t0,
                 { resetTimeoutOnProgress: true, signal, timeout, onprogress })
  • arguments = only the model-supplied tool args. No session id.
  • The MCP SDK adds _meta only for progressToken, never a conversation/session id.
  • No custom session header is sent.

Per the MCP spec's Statelessness section, any cross-request identifier "MUST be referenced by an explicit identifier the client passes on each request" — the client simply doesn't pass one.

Rejected: track an MCP-session-id → child-session map

Two independent blockers:

  1. No MCP session id exists in our config. We use WithStateLess(true) (server.go:121) → StatelessSessionIdManager: Generate() returns "" (streamable_http.go:1299-1301) and Validate() ignores client ids (:1303-1306). Each request is an ephemeral session whose "life is the same as the request" (:335-336). Nothing to key a map on. resp.ID (the child id) comes from the OpenCode platform HTTP API on a different connection from the MCP transport; they never meet.

  2. Even stateful, the granularity is wrong. If we flipped to WithStateful for a stable Mcp-Session-Id, it still wouldn't identify the OpenCode conversation. OpenCode keeps one MCP client per configured server, shared across all conversations (binary registry keyed by server name: clients[serverName], defs[serverName], status[serverName]). So the mapping is 1 mcp-session-id → N opencode-sessions — we still couldn't tell which child called. This matches the spec: "an open connection... is not a conversation or session... a server must not treat connection identity as a proxy for conversation continuity."

Conclusion

The only correlation point is that the child id equals the OpenCode session id (launcher.go:171, resp.ID), known at launch. There is no transport-level channel to hand it back, so it must reach the child as content — i.e. injected into the composed prompt. Any header/_meta/map approach would require an upstream OpenCode change we don't control.

## Research: can the server identify the caller without the client passing an id? Investigated whether MCP `_meta`, HTTP headers, or an MCP-session-id → child-session map could let `send_message_to_parent` know its own `child_session_id` without the child passing it. **All rejected.** The prompt-injection fix (inject `childID` into the composed prompt) stands as the only self-contained solution. ### Rejected: `_meta` / HTTP headers / `HTTPContextFunc` The server *can* read these — mcp-go v0.43.2 exposes `WithHTTPContextFunc` (`streamable_http.go:113`) and always puts `r.Header` in handler context (`:393`). But they only help if the **client** stamps its session identity, and OpenCode doesn't. Decompiled from the OpenCode binary (1.17.15), the tool-call site is: ```js await X.callTool({ name: Q.name, arguments: W || {} }, t0, { resetTimeoutOnProgress: true, signal, timeout, onprogress }) ``` - `arguments` = only the model-supplied tool args. No session id. - The MCP SDK adds `_meta` **only** for `progressToken`, never a conversation/session id. - No custom session header is sent. Per the MCP spec's Statelessness section, any cross-request identifier *"MUST be referenced by an explicit identifier the client passes on each request"* — the client simply doesn't pass one. ### Rejected: track an MCP-session-id → child-session map Two independent blockers: 1. **No MCP session id exists in our config.** We use `WithStateLess(true)` (`server.go:121`) → `StatelessSessionIdManager`: `Generate()` returns `""` (`streamable_http.go:1299-1301`) and `Validate()` ignores client ids (`:1303-1306`). Each request is an ephemeral session whose "life is the same as the request" (`:335-336`). Nothing to key a map on. `resp.ID` (the child id) comes from the OpenCode platform HTTP API on a different connection from the MCP transport; they never meet. 2. **Even stateful, the granularity is wrong.** If we flipped to `WithStateful` for a stable `Mcp-Session-Id`, it still wouldn't identify the OpenCode conversation. OpenCode keeps **one MCP client per configured server, shared across all conversations** (binary registry keyed by server name: `clients[serverName]`, `defs[serverName]`, `status[serverName]`). So the mapping is `1 mcp-session-id → N opencode-sessions` — we still couldn't tell which child called. This matches the spec: *"an open connection... is not a conversation or session... a server must not treat connection identity as a proxy for conversation continuity."* ### Conclusion The only correlation point is that the child id **equals** the OpenCode session id (`launcher.go:171`, `resp.ID`), known at launch. There is no transport-level channel to hand it back, so it must reach the child as content — i.e. injected into the composed prompt. Any header/`_meta`/map approach would require an upstream OpenCode change we don't control.
Author
Owner

No longer relevant after PR #338: child results are delivered to parents automatically, and get_current_session_id now identifies the calling session for explicit mid-task messages.

No longer relevant after PR #338: child results are delivered to parents automatically, and get_current_session_id now identifies the calling session for explicit mid-task messages.
dries closed this issue 2026-07-16 00:56:10 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dries/ocman#333
No description provided.