refactor(mobile): parse the launch outcome into a named type at its boundary

anti-slop/no-object-parameters flagged terminalLaunchWarning's `result: object`.
The rule is pointing at a real seam rather than a style nit: the helper advertised
a loose object and did the narrowing inside itself, so every caller handed it
unparsed wire data and nothing downstream held a real type.

Parsed at the boundary instead. parseTerminalLaunchOutcome takes `unknown` and
returns TerminalLaunchOutcome | null, so the narrowing happens once, where the
untrusted payload enters, and the consumer works with a named type.

The type is taken from the shared contract rather than restated - a Pick over the
terminal member of AgentLaunchOutcome - so a change to that union fails here
instead of flowing through. `handle` is deliberately excluded: nothing reads it,
and requiring it would drop the warning off a reply that omitted one, which is a
behaviour change smuggled in under a typing change.

No assertion and no config exemption: reintroducing `as Partial<AgentLaunchResult>`
would trade this finding for the defect removed earlier in this branch, and the
rule is correct here.

The rule arrived with the merge-forward (#20781, newer than this branch's
merge-base), and anti-slop is not one of the changed-code gate's six scans - it
runs only repo-wide - which is why a clean local gate did not predict it.

Behaviour is unchanged across all five warning cases, and the positive case was
re-ablated on the new parser: dropping the warning reddens exactly it,
1 failed | 18 passed, restored byte-identical to 19 passed.
This commit is contained in:
Brennan Benson
2026-09-15 15:25:06 -07:00
parent e4939e5191
commit f8c8ee037d
@@ -14,6 +14,7 @@
import {
withoutReservedAgentCreateFields,
type AgentLaunchOutcome,
type AgentLaunchResult
} from '../../../src/shared/agent-launch-intent'
import type { TuiAgent } from '../../../src/shared/tui-agent'
@@ -59,27 +60,46 @@ export function readAgentLaunchCreateOutcome(result: unknown): AgentLaunchCreate
if (typeof worktreeId !== 'string' || !worktreeId.trim()) {
return null
}
const warning = terminalLaunchWarning(result)
const warning = parseTerminalLaunchOutcome(
'outcome' in result ? result.outcome : undefined
)?.warning?.trim()
return { worktreeId, ...(warning ? { warning } : {}) }
}
/**
* The startup failure a terminal launch reports: the workspace exists, the agent did not start
* (pty exhaustion). Dropping it is what lands the phone on an unexplained empty session.
* The terminal outcome, narrowed to the fields this reader consumes. Taken from the shared union
* rather than restated, so a change to the contract fails here instead of flowing through.
*
* Narrowed rather than asserted, because the payload is whatever the host sent — a reader that
* claims the contract's shape without checking it is how a malformed reply reaches the UI as a
* TypeError instead of a message.
* `handle` is deliberately not required: nothing here reads it, and demanding it would drop the
* warning off a reply that omitted it — a behaviour change smuggled in under a typing change.
*/
function terminalLaunchWarning(result: object): string {
if (!('outcome' in result) || !result.outcome || typeof result.outcome !== 'object') {
return ''
type TerminalLaunchOutcome = Pick<
Extract<AgentLaunchOutcome, { kind: 'terminal' }>,
'kind' | 'warning'
>
/**
* Parses the launch outcome, which arrives as whatever the host sent.
*
* A terminal launch reports its startup failure here: the workspace exists, the agent did not
* start (pty exhaustion). Dropping it is what lands the phone on an unexplained empty session.
*
* Parsed into a named type at this boundary rather than read off a loose `object`, and narrowed
* rather than asserted — a reader that claims the contract's shape without checking it is how a
* malformed reply reaches the UI as a TypeError instead of a message.
*/
function parseTerminalLaunchOutcome(outcome: unknown): TerminalLaunchOutcome | null {
if (
!outcome ||
typeof outcome !== 'object' ||
!('kind' in outcome) ||
outcome.kind !== 'terminal'
) {
return null
}
const outcome = result.outcome
if (!('kind' in outcome) || outcome.kind !== 'terminal') {
return ''
}
return 'warning' in outcome && typeof outcome.warning === 'string' ? outcome.warning.trim() : ''
const warning =
'warning' in outcome && typeof outcome.warning === 'string' ? outcome.warning : undefined
return { kind: 'terminal', ...(warning === undefined ? {} : { warning }) }
}
/**