Skip to content

Skill outcomes are dropped when a Message is queued into a running Turn #4026

Description

@Astro-Han

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions