Repository navigation
feat(agent-launch): let a caller reserve the chat session, and start terminal launches with the session picks - #22523
Conversation
…ession picks to a terminal launch
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe launch RPC now accepts a caller-reserved session ID for structured launches, validates it against the selected agent, includes it in operation fingerprints, and returns a dedicated error when that ID already exists. Terminal launch options now pass supported model, effort, and mode preferences to terminal and worktree creation. Surface factory contracts and preference conversion are defined in shared modules. Tests cover session reservation, preference forwarding, and spawned model arguments. Merge Risk: ⚪ Minimal · up to Reserved IDs for agents with hyphenated names now pass validation. No outstanding issue in the supplied evidence prevents merge after normal checks. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…t, and mint the fallback the same way
There was a problem hiding this comment.
ℹ️ No critical issues — a few comment/coverage nits inline.
Reviewed changes
- Caller-reserved chat session id —
agent.launchaccepts an optionalsessionId, threaded to the structured create on both the existing-workspace and create-worktree routes; the outcome reports the session that really exists. - New conflict code — a taken caller-named id is refused with
agent_launch_session_already_exists, deliberately not a definitive structured refusal, so the launch does not fall back to a terminal; the ledger records the failure for an existing workspace. - Replay fingerprint —
sessionIdis included when present; absent keys are dropped, so existing digests are unchanged. - Terminal session picks — both terminal routes now forward
sessionOptionsaslaunchPreferences/startupLaunchPreferences, so a terminal landing starts with the picked model/effort and no duplicate flags. - Shared converter + factory extraction —
toAgentLaunchPreferencesmoves tosrc/shared/agent-launch-preferences.ts(three renderer callers repointed), and the surface/workspace factory contracts move toagent-launch-surface-factories.ts.
ℹ️ Nitpicks
src/shared/rpc-contract/agent-launch-params.ts:70still describessessionOptionsas "Only the seedable string options a structured create accepts; a terminal launch ignores them." This PR makes the second clause false — the terminal routes now read them as model/effort/mode preferences. Worth updating alongside theAgentLaunchIntentdoc that already was.- The new
agent-launch-session-reservation.test.tscovers anexisting+ taken id +operationIdcollision but not thecreate-worktreetwin; the pane reservation test covers both target kinds. The behavior is correct by construction, but a regression to thetargetKind === 'existing'guard inlaunchFailureWithoutEffectsCodewould go unnoticed on the create-worktree lane.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| export const AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE = | ||
| 'agent_launch_session_already_exists' as const | ||
|
|
||
| // The caller-minted chat session id already names a session; the host created nothing. |
There was a problem hiding this comment.
the host created nothing is only true of the session. On a create-worktree launch the worktree already exists by the time this is raised, and the code still reaches agent.launch callers (the executor rethrows the cause even though the row stays unknown). Consider wording it as "the host created no session" so it is not read as "no effects at all".
There was a problem hiding this comment.
Caution
The new object-level refinement on AgentLaunch breaks an existing mobile test at runtime. AgentLaunch.omit({ operationId: true }) in mobile/src/tasks/mobile-agent-launch-architecture.test.ts:76 now throws Error: .omit() cannot be used on object schemas containing refinements (Zod 4.5.4). That file is excluded from mobile's test typecheck and from the root typecheck, so only the mobile verify job's pnpm test catches it — and this PR's src/shared/rpc-contract/** change triggers that job. Details inline.
Reviewed changes (since the prior review at 8be3f6e)
- Session id shape enforced at the wire — a caller-minted
sessionIdmust now start with${agent}_and be one token, via the newisStructuredAgentSessionIdFor; checked on bothagent.launchandagent.launchReplay. - Host fallback minting — the executor now mints through
createStructuredAgentSessionIdinstead of a barerandomUUID(), so every launch-created id has the same shape. AgentLaunchsplit — a module-privateAgentLaunchFieldsplain object plus a refinedAgentLaunch;AgentLaunchReplayis rebuilt from the base object (.required()on a refined object is forbidden in Zod 4).
ℹ️ Nitpicks
- The shape refinement is written out twice (
AgentLaunchandAgentLaunchReplay) instead of derived from one helper. PreviouslyAgentLaunchReplay = AgentLaunch.required(...)inherited any future refinement automatically; now a refinement added toAgentLaunchwould silently not apply to the replay method. A sharedwithLaunchRefinements(schema)wrapper would keep them in step.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 3835a653-3961-40a4-93c1-f0e034776465
📒 Files selected for processing (23)
src/main/agent-launch/agent-launch-executor.test.tssrc/main/agent-launch/agent-launch-executor.tssrc/main/agent-launch/agent-launch-prompt-delivery.tssrc/main/agent-launch/agent-launch-surface-factories.tssrc/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.tssrc/main/runtime/rpc/errors.tssrc/main/runtime/rpc/methods/agent-launch-session-reservation.test.tssrc/main/runtime/rpc/methods/agent-launch-surfaces.tssrc/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.tssrc/main/runtime/rpc/methods/agent-launch-worktree-creation.tssrc/main/runtime/rpc/methods/agent-launch.tssrc/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.tssrc/renderer/src/lib/launch-agent-web-host-tab.tssrc/renderer/src/lib/runtime-agent-background-create.tssrc/renderer/src/runtime/agent-session-create-operation.tssrc/renderer/src/runtime/agent-session-operation-id.test.tssrc/shared/agent-launch-intent.tssrc/shared/agent-launch-operation.tssrc/shared/agent-launch-preferences.test.tssrc/shared/agent-launch-preferences.tssrc/shared/agent-launch-session-already-exists.tssrc/shared/rpc-contract/agent-launch-params.tssrc/shared/structured-agent-session-create.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.
| /** A caller-minted session id must be shaped like every id the host mints, so an id still names | ||
| * its lane on sight. */ | ||
| function refuseSessionIdForAnotherAgent( | ||
| launch: { agent: string; sessionId?: string | undefined }, | ||
| ctx: z.RefinementCtx | ||
| ): void { | ||
| if (launch.sessionId && !isStructuredAgentSessionIdFor(launch.agent, launch.sessionId)) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| path: ['sessionId'], | ||
| message: 'Launch session id must be named for its agent' | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Only check the session-id shape for agents that can create a structured session.
refuseSessionIdForAnotherAgent runs for every TuiAgent. For agents whose names contain -, such as claude-agent-teams, mimo-code, and qwen-code, isStructuredAgentSessionIdFor can never return true. Its regex /^[A-Za-z0-9_]+$/ rejects the - in the required ${agent}_ prefix. So any sessionId on those launches fails validation.
The field doc says the reservation is "Ignored when the launch settles as a terminal". Those agents always launch in a terminal. A caller that mints a sessionId on every launch, as it does for paneKey, would have those launches refused.
Choose one fix:
- Limit the check to structured providers, so the id stays ignored for other agents.
- Refuse
sessionIdfor non-structured agents with an explicit message, and update the field doc to say so.
Proposed fix
function refuseSessionIdForAnotherAgent(
launch: { agent: string; sessionId?: string | undefined },
ctx: z.RefinementCtx
): void {
- if (launch.sessionId && !isStructuredAgentSessionIdFor(launch.agent, launch.sessionId)) {
+ const structured = launch.agent === 'claude' || launch.agent === 'codex'
+ if (structured && launch.sessionId && !isStructuredAgentSessionIdFor(launch.agent, launch.sessionId)) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** A caller-minted session id must be shaped like every id the host mints, so an id still names | |
| * its lane on sight. */ | |
| function refuseSessionIdForAnotherAgent( | |
| launch: { agent: string; sessionId?: string | undefined }, | |
| ctx: z.RefinementCtx | |
| ): void { | |
| if (launch.sessionId && !isStructuredAgentSessionIdFor(launch.agent, launch.sessionId)) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['sessionId'], | |
| message: 'Launch session id must be named for its agent' | |
| }) | |
| } | |
| } | |
| /** A caller-minted session id must be shaped like every id the host mints, so an id still names | |
| * its lane on sight. */ | |
| function refuseSessionIdForAnotherAgent( | |
| launch: { agent: string; sessionId?: string | undefined }, | |
| ctx: z.RefinementCtx | |
| ): void { | |
| const structured = launch.agent === 'claude' || launch.agent === 'codex' | |
| if (structured && launch.sessionId && !isStructuredAgentSessionIdFor(launch.agent, launch.sessionId)) { | |
| ctx.addIssue({ | |
| code: z.ZodIssueCode.custom, | |
| path: ['sessionId'], | |
| message: 'Launch session id must be named for its agent' | |
| }) | |
| } | |
| } |
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the earlier blocking concern is resolved.
Reviewed changes
- Mobile fixture unblocked —
mobile/src/tasks/mobile-agent-launch-architecture.test.tsnow builds its older-host params from the exported un-refinedAgentLaunchFieldsbase instead ofAgentLaunch, so Zod 4's.omit()no longer throws while the legacy dispatcher is constructed. Verified locally:.omit()succeeds onAgentLaunchFieldsand still throws on the refinedAgentLaunch, while the shape check still rejects acodex_…id for a claude launch on bothAgentLaunchandAgentLaunchReplay. AgentLaunchFieldsexported — the un-refined base is re-exported fromsrc/main/runtime/rpc/methods/agent-launch-schemas.tswith a doc comment scoping it to building an older host's shape in tests; every receiver still parsesAgentLaunchorAgentLaunchReplay.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…identity, not placement The caller mints the session id so it knows which conversation it started; tab placement is not keyed on it. Also puts the terminal surface's doc comment back on createTerminalSurface.
…the wire contract The `sessionOptions` field doc still said a terminal launch ignores them, which this branch changed.
…t name, not the whole id A hyphenated agent name failed the one-token check, so any session id for such an agent was refused at the wire, while every other agent without a chat has its id ignored on the terminal.
There was a problem hiding this comment.
ℹ️ No new issues in this delta. The reserved-session-id wire check now validates only the token after the agent-name prefix. The still-open wording thread on
agent-launch-session-already-exists.ts:4is untouched by these commits, so this review is intentionally non-approving.
Reviewed changes
- Reserved-id shape check fixed —
isStructuredAgentSessionIdFornow strips the${agent}_prefix and validates only the remaining token, so a host-shaped id for a hyphenated agent (mimo-code_…) is no longer wrongly refused at the wire, while a hyphen inside the token is still rejected. - Wire tests added — one asserts a hyphenated-agent id is accepted, one asserts a hyphen after the agent name is still refused.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Review status: ready to merge once shard 1 is re-runHead: What changed during review
The PR body has been corrected to match: the fingerprint and wire-compatibility wording was still about placement, and the Architecture
Electron QA (hidden dev build of this branch,
|
| Scenario | Result |
|---|---|
Mobile-shaped launch (no sessionId, no sessionOptions) |
Chat opens with id claude_dc348233_…. A message round-trips: the reply shown is a provider usage-limit error from the test account, not an app error. |
Caller-chosen sessionId |
Chat opens and the result reports the same id. |
Same sessionId again |
Refused with agent_launch_session_already_exists; no new tab or terminal. |
codex_… or hyphenated id on a claude launch |
Refused at the request (invalid_argument). |
Terminal launch with sessionOptions: { model: "opus" } |
Process started as claude --dangerously-skip-permissions --model opus. |
Terminal launch without sessionOptions |
claude --dangerously-skip-permissions, no --model. |
What this QA did not cover:
- Launches with an
operationId. The desktop renderer can't be the caller for these: an existing check that the caller has an identity refuses it withagent_session_identity_required, and this PR doesn't touch that check. Ledger replay with anoperationIdis covered by unit tests. - Mobile emulator QA. Nothing in mobile changes. Mobile's host-side path was exercised with its exact params in the first row above.
- Windows and SSH. No platform-specific code changed, and SSH hosts don't support structured chat.
CI
Everything passes except tests node 24 1/8 (and verify, which depends on it). Its one failure is Sidebar.test.tsx > does not start a full worktree scan while the startup session is hydrating, which fails in repo-scan-failure.ts. That breakage is already on main, this PR doesn't touch the file, and the fix is #22564. Re-run shard 1 after #22564 lands. e2e / ssh docker watcher isolation was still pending when this was written.
Follow-ups (not in this PR)
- Once the desktop launcher moves onto
agent.launch, it must read the chat'ssessionIdfrom the outcome and not assume its own id was used. An older host silently drops the field and chooses its own id. modeis honoured on a terminal but dropped when the launch starts a chat, andfastModeis the reverse. This mismatch already existed; it becomes visible once desktop sends picks.- On
create-worktree, a taken id leaves the new workspace in place without an agent. This is the same leftover already accepted for a live reserved pane, and it can only happen through a caller bug.






ELI5
When Orca starts an agent as a native chat, it makes up an id for the chat session: the conversation's identity. This PR lets the caller that starts the launch choose that id instead, so the launcher knows which conversation it created before the reply arrives, and a retry of the same launch refers to the same conversation. It also fixes a mismatch the move would otherwise expose: a launch that becomes a terminal ignored the model the user picked, while the same launch as a chat honoured it.
No shipped caller sends either field yet. Mobile is the only client of
agent.launchtoday, and it sends neithersessionIdnorsessionOptions. The one change a user can reach: a chat that mobile starts now gets a session id shaped like every other (claude_…, underscores) instead of a bare hyphenated one; nothing reads the shape back, and ids already saved are untouched.What Changed
1.
agent.launchaccepts an optionalsessionIdfor the chat session a structured launch creates.sessionIdconfirms it. It reaches the create on both routes: an existing workspace, and a chat created along with a new workspace.SessionIdschema, and refused unless it is named for its agent (claude_…for a claude launch), followed by one token of letters, digits and underscores. This is checked on bothagent.launchandagent.launchReplay. Only the part after the agent name is checked for the one-token shape, so an agent whose own name has a hyphen (mimo-code_…) is not refused at the wire; like any agent without a chat, its launch settles as a terminal and ignores the id.2. A taken id is refused with its own code,
agent_launch_session_already_exists. The host already refuses a new session under an existing id (agent_session_conflict, from the null-fence check inagent-session-reservation-admission.ts:185). This PR adds no second guard. It gives that refusal a stable code when the caller named the id, and makes sure it is not treated as a reason to fall back:agent_session_conflictis already outside the set of refusals that may fall back (agent-session-definitive-refusal.ts), and the new error is not a structured-refusal type at all. A caller that named a taken session has a bug to see, not an agent in a different surface.agent_session_operation_unknown. Oncreate-worktreethe workspace already exists by the time the chat is refused, so the row stays unknown, as it does for the pane refusal.agent.launch, and onagent.launchReplayfor an existing workspace. Oncreate-worktree,agent.launchReplayanswersagent_session_operation_unknown, as it does for a live reserved pane, because the workspace was already created.3.
sessionIdis in the replay fingerprint when present. A retry that minted a different session is a different request: replaying the first answer would hand the caller a conversation it did not mint. Absent keys are dropped by the canonical form, so every existing digest is unchanged.4. A terminal launch now starts with the model, effort and mode the launch carried.
sessionOptionswere read by the structured create but dropped on the terminal route, so the same launch got a different model depending on which surface the host chose. Both terminal routes now pass them to the runtime as launch preferences, which the startup plan already ranks above configured args:createTerminal({ launchPreferences })create-worktree→startupLaunchPreferences, a field the worktree create already had and orchestration already uses5. One converter.
toAgentLaunchPreferencesmoves fromsrc/renderer/src/runtime/agent-session-create-operation.tstosrc/shared/agent-launch-preferences.ts. The three renderer callers are repointed; no second copy is left behind.6. Two small structural changes the above needed:
terminalLaunchInputs, instead of being repeated at the two call sites.AgentLaunchSurfaceFactory,AgentLaunchWorkspaceFactory, the structured refusal error) move toagent-launch-surface-factories.ts. They are what each caller implements, not part of the sequencing the executor runs, and moving them keeps the executor under its line limit without trimming anything.Why
Why the caller mints the chat session id. The session id is the conversation's identity, and a launcher that mints it knows which conversation it started without waiting for the reply. A retry carries the same id: under an
operationIdthe host answers it from the ledger, and without one the host refuses a second session under that id instead of starting another chat. The desktop's direct chat launch already works this way: it mints the id and sends it onagentSession.create(launch-structured-agent-session.ts:94). This bringsagent.launchin line and replaces its barerandomUUID().This field is not how a caller places the chat's tab. A chat tab's id is currently derived from its session id, but that derivation is being removed in follow-up work: tabs get their own id, reserved by the caller through
paneKeyfor both terminals and chats, and point at the session throughentityId. Nothing in this PR keys placement on the session id.Why the id's shape is enforced. The shared minting helper (
createStructuredAgentSessionId) documents two properties of every session id: it is prefixed with its agent, so an id names its lane on sight, and it is underscore-only, so it stays one token inside tab ids and log keys. Nothing reads the prefix back today (searched; the one match is the writer itself), but session ids are persisted, so the shape is decided once. Letting callers mintcodex_…for a claude launch, or a hyphenated id, would make that documented invariant false for exactly the ids that crossed the wire. The predicate (isStructuredAgentSessionIdFor) lives next to the writer.The host's own fallback now uses the same helper. When no id is sent,
agent.launchused to mint a bare hyphenatedrandomUUID(), the only structured-session id in the app without the shape. It now callscreateStructuredAgentSessionId, so every id a launch creates, caller-minted or not, has one shape. Ids already persisted are untouched; the sharedSessionIdschema still accepts them.Why the model belongs on the terminal route now. Once the desktop launcher calls
agent.launch, it sends the user's remembered model insessionOptionsthe way it builds its own startup plan today. If the host dropped it whenever the launch became a terminal, moving the desktop launcher ontoagent.launchwould silently lose the model on every terminal launch. The host building the preference, rather than the desktop spelling a--modelflag into args, keeps the knowledge of how each CLI spells a model in one place: the shared startup plan.Linked Issue
Part of the
agent.launchseries (#22291, #22116, #22108). No separate issue.Visual Proof
N/A. No caller sends either field and no renderer behaviour changes; the three renderer edits only change where the converter is imported from. The checks that matter are that a launch sending nothing is untouched, that a reserved id reaches the create, and that a picked model reaches the terminal's real launch command. All are pinned below.Testing
pnpm tcclean,oxlintclean on every changed file, and 20,111 tests pass across 1,809 files in the affected suites (src/shared, theagent.launchRPC methods, the executor, the renderer runtime and lib directories, the pty-connection directory, and theorca-runtime.test.tsaggregator).Two lineage tests (
falls back to cwd lineage when the caller terminal handle is staleandkeeps cwd-inferred lineage best-effort when the cwd parent cannot be resolved) failed in that combined local run, and a CI node shard failed the same family once on this PR's first head, all withWorktree created but not found in listing: /tmp/workspaces/…. This is a frequent existing flake, not this PR: this test family (the lineage tests andworktree scan cache: expires scans per repo without coupling sibling repos, which failed on this PR's third head withlistWorktreescalled 4 times instead of 3) fails in 14 of 37 recent failed PR Checks runs on unrelated branches, among themlead-status-pr-c,claude-init-deadline,chat-file-links-open,browser-capture-owns-paint-holdandstructured-send-recovery. A probe confirmed this PR's new runtime tests list worktrees exactly once each and nothing after they return, so they are not a source of the extra call. The same shard passed on this PR's next head with the same test code, and the tests pass alone and in the fullorca-runtime.test.tsaggregator on both this branch and main. The likely mechanism is in the test harness: those tests queue one-shotlistWorktreesresults withmockResolvedValueOnce, and the sharedbeforeEachre-armslistWorktreeswithmockResolvedValuewithoutmockReset, so a result left or stolen by another test shifts their queue. That is worth its own fix and is not part of this PR.Against the real runtime (
terminal-creation-and-readiness-part-12.spec.ts, a realOrcaRuntimeService):launchPreferences: { model: 'opus' }spawns with'--model' 'opus'in its command--modelAt the
agent.launchboundary (agent-launch-session-reservation.test.ts):agent_launch_session_already_exists, and no terminal is created in its placemimo-code_…) is accepted, and ignored on its terminal like any other agent without a chat; a hyphen after the agent name is still refusedagent.launchReplaytoooperationId, the refusal is recorded as a failed row, a retry is answered without touching the host, andagent.launchReplayanswers with the refusal codeModel on the terminal route (
agent-launch-terminal-preferences.test.ts):launchPreferences; none are added when the launch carried none; the picks survive a downgrade to a terminal; options that are not launch preferences are droppedcreate-worktreeterminal launch passes them asstartupLaunchPreferences; none when the launch carried none; none when the new workspace gets a chat insteadMobile. Making
AgentLauncha refined schema removed.omitfrom it, andmobile/src/tasks/mobile-agent-launch-architecture.test.tsbuilds an older host's params withAgentLaunch.omit({ operationId: true }). The unrefined base is now exported asAgentLaunchFieldsand that test uses it; every receiver still parses the refinedAgentLaunch/AgentLaunchReplay. With the old line, 8 of that file's 13 tests fail; with the fix, all 13 pass. Mobiletypecheckis clean and the mobile tests-typecheck ratchet passes.Converter (
agent-launch-preferences.test.ts): the existing case moved from the renderer test, plus the empty case.Ablation, run at the final head. Each production change was removed one at a time, the suites above re-run, then restored:
sessionIdin theagent.launchschemarandomUUID())agent_launch_session_already_existsagent.launchReplayanswers with it)sessionIdin the fingerprintlaunchPreferenceson the existing-workspace terminaloptionsin the shared terminal inputs (both routes)startupLaunchPreferencesoncreate-worktreelaunchPreferencesinto the startup plan (pre-existing)--modeltestcreateStructuredAgentSessionId(restored torandomUUID())Every row went red and was restored.
Notes
Wire compatibility. A new optional field on an existing method, and a new error code only that field can trigger. No new stream opcode. Old client → new host: sends nothing, no change. New client → old host: the field is stripped and the host mints its own id; the outcome's
sessionIddiffers from the one the caller minted, so the caller learns from the outcome which conversation was really started, and the launch still creates exactly one chat. The outcome is the reconciliation: a caller must not rely on its own id being honoured before the reply arrives. No capability advertises support because no caller depends on it yet; a caller that needs the id before the reply will need one.sessionOptionson the terminal route is a behaviour change for any client that sends them. Today that is no client (mobile sends none), and it is the direction the desktop migration needs. A future client that sends picks and lands on a terminal now gets those picks instead of the configured default args.Performance. One conversion per launch that sends options. No extra IPC, render or hot-path work.
Cross-platform and SSH. No platform-specific code. The model preference goes through the same shared startup plan on every host, and the session id goes through the same structured create on local and paired hosts; SSH hosts do not support structured chat, so the session id never applies there.
Agent skill upstream boundary
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)