Summary
turn.message.submit answers with a Skill outcome only when the Message opened its own Turn. When the same Message is queued into a running Turn, the Skill is still expanded, but the outcome has nowhere to travel — so a Skill that failed to resolve is reported to the user on an idle Session and silently dropped on a busy one.
What happens today
TurnMessageSubmitResult carries skillInvocation on two of its four variants:
// packages/runtime-host/src/protocol/message.ts:102
export type TurnMessageSubmitResult =
| { readonly disposition: 'steering'; readonly queueRevision: number }
| { readonly disposition: 'followup'; readonly queueRevision: number }
| { readonly disposition: 'turn_started'; readonly turnId: string; readonly skillInvocation?: SkillInvocationResult }
| { readonly disposition: 'blocked'; readonly skillInvocation: SkillInvocationResult };
The data exists on the queued path — prepareSkillInvocationContent returns skillInvocation on both its ready and rejected answers (root-turn-coordinator.ts:1290-1302) — but the public prepareMessage narrows the type to { kind: 'ready'; content } | { kind: 'rejected'; error } and drops it (root-turn-coordinator.ts:1216-1233). The Desktop then has nothing to report and synthesises an empty result (runtime-host-session-execution-ipc-main.ts, the steering branches of both sessions:send and sessions:submitMessage).
Two user-visible consequences, both on a Session with a running Turn:
- Partial failure is silent.
/skill:good /skill:typo look at this expands good, and the receipts reach the durable content, but the failed entry for typo never reaches the renderer. The same message on an idle Session raises "some Skills could not be invoked".
- Total failure degrades to a generic error. An idle Session answers the structured
blocked disposition, which the Desktop renders as "Skill invocation failed, message not sent". A busy Session raises operation_conflict with a prose message, which both the composer and the side-chat panel can only render as a generic send failure — and it cannot be localised.
Why now
This is a pre-existing gap in the protocol, but #4020 makes it easier to reach. The Desktop used to sniff /skill: tokens out of the message text and route them around admission; it no longer does, because the Host is the admission authority. requiresExactTurn counts only explicit skillIds and an orchestration override as exact-Turn intent — a /skill: token in the text is expanded on the queued path too (message-coordinator.ts:2320). So a textual Skill sent at a busy Session now steers instead of failing closed, which is correct, and lands exactly on the silent path.
Cleanest fix
Make the Skill outcome a property of the admission answer rather than of one disposition. Resolving Skills is part of preparing the Message, which every admission path does; which Turn the Message landed in says nothing about it. Hoisting the field is what stops one path from being able to drop it:
export type TurnMessageSubmitResult = {
readonly skillInvocation: SkillInvocationResult; // empty when the Message asked for no Skill
} & (
| { readonly disposition: 'steering' | 'followup'; readonly queueRevision: number }
| { readonly disposition: 'turn_started'; readonly turnId: string }
| { readonly disposition: 'blocked' }
);
With that in place, a hard Skill failure on the queued path can answer blocked — the same structured answer the idle path already gives — instead of operation_conflict.
Surface:
packages/runtime-host/src/protocol/message.ts — the type and its codec.
packages/runtime-host/src/server/root-turn-coordinator.ts — stop narrowing prepareMessage's answer.
packages/runtime-host/src/server/message-coordinator.ts — carry it onto the queued dispositions; answer blocked instead of operation_conflict when preparation rejects.
apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts — report it on the steering branches instead of EMPTY_SKILL_INVOCATION.
One thing to decide while implementing
A retry under the same Message identity is answered from the durable admission record. That record stores submittedIntent but not the Skill outcome, so a retried submit would answer with an empty invocation unless the outcome is persisted alongside it. Either persist it, or state explicitly that receipts are reported once and a retry answers only the disposition.
Acceptance
- On a Session with a running Turn, a message with one resolvable and one unresolvable
/skill: token reports the same failed entry the idle Session reports.
- On the same Session, a message whose Skills all fail to resolve answers
blocked, and the renderer shows the Skill-specific message rather than a generic send failure.
- A test covers the queued path directly; today no test would notice the field going missing again.
Summary
turn.message.submitanswers with a Skill outcome only when the Message opened its own Turn. When the same Message is queued into a running Turn, the Skill is still expanded, but the outcome has nowhere to travel — so a Skill that failed to resolve is reported to the user on an idle Session and silently dropped on a busy one.What happens today
TurnMessageSubmitResultcarriesskillInvocationon two of its four variants:The data exists on the queued path —
prepareSkillInvocationContentreturnsskillInvocationon both itsreadyandrejectedanswers (root-turn-coordinator.ts:1290-1302) — but the publicprepareMessagenarrows the type to{ kind: 'ready'; content } | { kind: 'rejected'; error }and drops it (root-turn-coordinator.ts:1216-1233). The Desktop then has nothing to report and synthesises an empty result (runtime-host-session-execution-ipc-main.ts, the steering branches of bothsessions:sendandsessions:submitMessage).Two user-visible consequences, both on a Session with a running Turn:
/skill:good /skill:typo look at thisexpandsgood, and the receipts reach the durable content, but thefailedentry fortyponever reaches the renderer. The same message on an idle Session raises "some Skills could not be invoked".blockeddisposition, which the Desktop renders as "Skill invocation failed, message not sent". A busy Session raisesoperation_conflictwith a prose message, which both the composer and the side-chat panel can only render as a generic send failure — and it cannot be localised.Why now
This is a pre-existing gap in the protocol, but #4020 makes it easier to reach. The Desktop used to sniff
/skill:tokens out of the message text and route them around admission; it no longer does, because the Host is the admission authority.requiresExactTurncounts only explicitskillIdsand an orchestration override as exact-Turn intent — a/skill:token in the text is expanded on the queued path too (message-coordinator.ts:2320). So a textual Skill sent at a busy Session now steers instead of failing closed, which is correct, and lands exactly on the silent path.Cleanest fix
Make the Skill outcome a property of the admission answer rather than of one disposition. Resolving Skills is part of preparing the Message, which every admission path does; which Turn the Message landed in says nothing about it. Hoisting the field is what stops one path from being able to drop it:
With that in place, a hard Skill failure on the queued path can answer
blocked— the same structured answer the idle path already gives — instead ofoperation_conflict.Surface:
packages/runtime-host/src/protocol/message.ts— the type and its codec.packages/runtime-host/src/server/root-turn-coordinator.ts— stop narrowingprepareMessage's answer.packages/runtime-host/src/server/message-coordinator.ts— carry it onto the queued dispositions; answerblockedinstead ofoperation_conflictwhen preparation rejects.apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts— report it on the steering branches instead ofEMPTY_SKILL_INVOCATION.One thing to decide while implementing
A retry under the same Message identity is answered from the durable admission record. That record stores
submittedIntentbut not the Skill outcome, so a retried submit would answer with an empty invocation unless the outcome is persisted alongside it. Either persist it, or state explicitly that receipts are reported once and a retry answers only the disposition.Acceptance
/skill:token reports the samefailedentry the idle Session reports.blocked, and the renderer shows the Skill-specific message rather than a generic send failure.