Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/designs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,5 +429,5 @@ check enforces the mechanical half. Full rationale:
| DL-303 | The public `SessionInjection.traceparent` field (`compass.proto`, field 4) exposes the Server's active-span W3C `traceparent` on the SubscribeAgentSession observation surface, so a public subscriber can join a SessionInjection to the message's server-side trace (Matt ruled OQ2 = yes at the T4b freeze gate). Additive string scalar beside `from_handle` (a W3C trace-context token `00-<trace-id>-<span-id>-<flags>`, not an identifier); EMPTY when the Server had no active span; trace machinery never blocks or fails a delivery. The internal control-op (`agent.proto` SteerControl/DeliverControl) and causal-link (`agent_gateway.proto` CommsCallRequest.trigger_traceparent) legs are internal-tree, not a public-API decision | Active (Matt, 2026-08-27) | [server+runner OTel §T4](observability/compass-server-runner-otel/design.md#t4--trace-proto-fields-control-ops-public-observation-causal-link-stacked-seam) |
| DL-310 | SubscribeAgentSession sends a leading zero-payload registration-ack frame (existing AgentSessionFrame, session_id only, no event, UNSPECIFIED state) immediately after subscribe() registers, before the live tail — mirroring SubscribeEvents' snapshotBoundary / SubscribeComms' commsSnapshotBoundary. It makes "open the tail before the post" a server-guaranteed happens-before, closing the idle-session drop where a driven injection races subscribe() and fans to zero subscribers (no replay ring). Not a replay/resync/reattach — the ack carries no history (RIG-3044, e2e flake TestLegThreeFourSpawnAndMessaging). | Active (Matt, 2026-08-31) | [first-turn delivery](agent/compass-first-turn-delivery/design.md) |
| DL-318 | Pre-stream/inner error and abort content surface on the session trace as a dedicated `SessionError` message (`compass.v1.SessionEvent` oneof field 10) with a `SessionErrorKind` discriminator (ERROR \| ABORTED), a failure `message`, and an optional HTTP `status` — not `SessionNotice`, not a comms `Message` block (RIG-2616) | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) |
| DL-319 | Error/abort emit rule: `reason=error` emits `SessionError(ERROR)` AND the existing `ERRORED` lifecycle transition (additive — board/presence/delivery key off `ERRORED`); `reason=aborted` emits `SessionError(ABORTED)` with NO lifecycle transition (abort is not a crash) and replaces the prior counted-`UnmappedEvent` staging | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) |
| DL-320 | The `SessionError` trace frame rides the FrameSink never-drop PRIORITY lane (not the bounded drop-oldest trace queue), via an `isSessionError` classifier extending the `frame-sink.ts` priority predicate — matching the `SessionInjection` never-drop carve-out, so surfaced failure content is as durable-on-the-spine as the lifecycle transition it reports | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) |
| DL-322 | Error/abort emit rule: `reason=error` emits `SessionError(ERROR)` AND the existing `ERRORED` lifecycle transition (additive — board/presence/delivery key off `ERRORED`); `reason=aborted` emits `SessionError(ABORTED)` with NO lifecycle transition (abort is not a crash) and replaces the prior counted-`UnmappedEvent` staging | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) |
| DL-323 | The `SessionError` trace frame rides the FrameSink never-drop PRIORITY lane (not the bounded drop-oldest trace queue), via an `isSessionError` classifier extending the `frame-sink.ts` priority predicate — matching the `SessionInjection` never-drop carve-out, so surfaced failure content is as durable-on-the-spine as the lifecycle transition it reports | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) |
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ Status: Active
Refs: RIG-2616 (parent RIG-974). Surface: compass-agent event mapper +
`compass.v1` session-trace contract.

> Ledger-id note (post-freeze, annotate-don't-rewrite): a concurrent-merge
> window allocated this record's emit-rule and never-drop-routing ledger rows
> the same DL ids as an earlier-dated UI record. They were renumbered on
> `DECISIONS.md` to keep ids unique: **DL-319 → DL-322** (emit rule),
> **DL-320 → DL-323** (FrameSink never-drop routing). DL-318 is unchanged. The
> in-body citations below read as point-in-time; the ledger rows are canonical.

## Problem / Intent

An inner-stream failure or a deliberate abort reaches the compass-agent event
Expand Down
3 changes: 3 additions & 0 deletions packages/compass-agent/src/compassv1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ export {
// message schemas are the oneof payloads it constructs with `create`.
type SessionAssistantText,
SessionAssistantTextSchema,
type SessionError,
SessionErrorKind,
SessionErrorSchema,
type SessionEvent,
SessionEventSchema,
type SessionFileDiff,
Expand Down
107 changes: 88 additions & 19 deletions packages/compass-agent/src/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
AgentPlanEntryStatus,
AgentSessionState,
AgentToolCallStatus,
SessionErrorKind,
type SessionEvent,
} from "./compassv1";
import { EventMapper, type MapOutput } from "./mapping";
Expand Down Expand Up @@ -355,29 +356,97 @@ describe("EventMapper — non-content inner events emit nothing", () => {
}
});

describe("EventMapper — inner error: crash → session ERRORED vs abort → counted unmapped", () => {
// The pi-ai `error` variant carries reason "error" | "aborted" — NOT the same
// failure class:
// - "error" = an inner/provider failure → a session frame to ERRORED.
// - "aborted" = a deliberate cancel, NOT a crash → surfaced as a counted
// UnmappedEvent (never conflated with ERRORED, never dropped).
test('reason "error" → one session frame in state ERRORED', () => {
expect(
soleSessionState(
mapper().map(upd({ type: "error", reason: "error", error: partial() })),
),
).toBe(AgentSessionState.ERRORED);
describe("EventMapper — inner error: error → SessionError + ERRORED vs abort → SessionError only", () => {
// The pi-ai `error` variant carries reason "error" | "aborted". Both surface
// their content as a SessionError trace event; the failure class differs:
// - "error" = an inner/provider failure → the SessionError(ERROR) content
// frame AND the ERRORED lifecycle transition (content first).
// - "aborted" = a deliberate cancel, NOT a crash → the SessionError(ABORTED)
// content frame only, no lifecycle transition, never a counted unmapped.
// `status` rides the frame only when the provider surfaced an HTTP status.
const errored = (over: Partial<AssistantMessage>): AssistantMessage => ({
...partial(),
...over,
});

test('reason "aborted" → one counted UnmappedEvent, NOT ERRORED', () => {
test('reason "error" → SessionError(ERROR) content frame then ERRORED lifecycle', () => {
const out = mapper().map(
upd({ type: "error", reason: "aborted", error: partial() }),
upd({
type: "error",
reason: "error",
error: errored({ errorMessage: "provider exploded", errorStatus: 500 }),
}),
);
expect(out).toHaveLength(1);
const frame = out[0];
expect(frame.kind).toBe("unmapped");
if (frame.kind === "unmapped")
expect(frame.eventType).toBe("message_update:error");
expect(out).toHaveLength(2);
const [content, lifecycle] = out;
if (content.kind !== "session")
throw new Error(`expected session frame, got ${content.kind}`);
const ev = defined(content.value.typedEvent, "typed session event").event;
if (ev.case !== "sessionError")
throw new Error(`expected sessionError, got ${ev.case}`);
expect(ev.value.kind).toBe(SessionErrorKind.ERROR);
expect(ev.value.message).toBe("provider exploded");
expect(ev.value.status).toBe(500);
if (lifecycle.kind !== "session")
throw new Error(`expected session frame, got ${lifecycle.kind}`);
expect(lifecycle.value.typedEvent).toBeUndefined();
expect(lifecycle.value.state).toBe(AgentSessionState.ERRORED);
});

test('reason "aborted" → one SessionError(ABORTED), no lifecycle, no unmapped', () => {
const out = mapper().map(
upd({
type: "error",
reason: "aborted",
error: errored({ errorMessage: "user cancelled" }),
}),
);
const ev = soleTyped(out).event;
if (ev.case !== "sessionError")
throw new Error(`expected sessionError, got ${ev.case}`);
expect(ev.value.kind).toBe(SessionErrorKind.ABORTED);
expect(ev.value.message).toBe("user cancelled");
});

test("a surfaced errorStatus rides the frame; an absent one leaves status unset", () => {
const withStatus = soleTyped(
mapper().map(
upd({
type: "error",
reason: "aborted",
error: errored({ errorStatus: 429 }),
}),
),
).event;
if (withStatus.case !== "sessionError")
throw new Error(`expected sessionError, got ${withStatus.case}`);
expect(withStatus.value.status).toBe(429);

// The literal-0 boundary is the whole reason #sessionError guards on
// `errorStatus !== undefined` rather than truthiness: HTTP status 0 is a
// real provider sentinel (network-level failure / no response), and a
// truthiness guard would silently drop it. Status 0 must ride the frame.
const zeroStatus = soleTyped(
mapper().map(
upd({
type: "error",
reason: "aborted",
error: errored({ errorStatus: 0 }),
}),
),
).event;
if (zeroStatus.case !== "sessionError")
throw new Error(`expected sessionError, got ${zeroStatus.case}`);
expect(zeroStatus.value.status).toBe(0);

const noStatus = soleTyped(
mapper().map(upd({ type: "error", reason: "aborted", error: partial() })),
).event;
if (noStatus.case !== "sessionError")
throw new Error(`expected sessionError, got ${noStatus.case}`);
expect(noStatus.value.status).toBeUndefined();
// An absent errorMessage collapses to the empty string, never undefined.
expect(noStatus.value.message).toBe("");
});
});

Expand Down
58 changes: 41 additions & 17 deletions packages/compass-agent/src/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
// a monotonic per-event counter. `atUnixMs` comes from an injectable clock so
// tests are deterministic.

import type { AssistantMessageEvent } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, AssistantMessageEvent } from "@oh-my-pi/pi-ai";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
import {
type AgentPlanEntry,
Expand All @@ -43,6 +43,8 @@ import {
AgentToolCallStatus,
create,
SessionAssistantTextSchema,
SessionErrorKind,
SessionErrorSchema,
type SessionEvent,
SessionEventSchema,
type SessionFileDiff,
Expand Down Expand Up @@ -246,6 +248,29 @@ export class EventMapper {
return { kind: "session", value };
}

// Build one SessionError trace frame from a pi-ai inner-error AssistantMessage
// (mapping.ts error arm). `kind` discriminates an unexpected failure (ERROR,
// paired with the ERRORED lifecycle transition) from a deliberate abort
// (ABORTED, no transition). `message` is the failure text (empty when the SDK
// surfaced none); `status` is set only when the provider surfaced an HTTP
// status, so a subscriber can tell "no status" from a literal 0. Routes
// through `#sessionEvent` for uniform id/clock stamping.
#sessionError(
kind: SessionErrorKind,
error: AssistantMessage,
): OutboundFrame {
return this.#sessionEvent({
case: "sessionError",
value: create(SessionErrorSchema, {
kind,
message: error.errorMessage ?? "",
...(error.errorStatus !== undefined
? { status: error.errorStatus }
: {}),
}),
});
}

// Build one SessionInjection trace frame — the agent-side observation that a
// channel message was injected into the live session as a steer or a deliver
// (design "steer/deliver split observation seam", T1). Public because the
Expand Down Expand Up @@ -296,27 +321,26 @@ export class EventMapper {
}
case "error": {
// The stream surfaced an inner error. `reason` splits the failure
// class (SDK: "aborted" | "error"):
// - "error" = an unexpected inner/provider failure → an ERRORED
// lifecycle transition (compass.proto:132 scopes ERRORED to the
// class (SDK: "aborted" | "error"), and both surface their content as
// a SessionError trace frame (DL-322): `inner.error.errorMessage` is
// the user-facing failure text, `inner.error.errorStatus` the provider
// HTTP status when one was surfaced.
// - "error" = an unexpected inner/provider failure → emit the
// SessionError(ERROR) content frame AND preserve the ERRORED
// lifecycle transition (compass.proto scopes ERRORED to the
// OOM/panic/engine-restart class; an inner stream error is that
// class).
// class, and board/presence/delivery key off it). Content first.
// - "aborted" = a deliberate steer/user cancel, NOT a crash —
// conflating it with ERRORED would misreport a normal abort as an
// engine failure. Surface it as a counted UnmappedEvent so it is
// logged + counted, never dropped, pending the abort-surfacing
// contract (a follow-up).
// engine failure. Emit only the SessionError(ABORTED) content
// frame, with NO lifecycle transition.
if (inner.reason === "error") {
return [this.#sessionState(AgentSessionState.ERRORED)];
return [
this.#sessionError(SessionErrorKind.ERROR, inner.error),
this.#sessionState(AgentSessionState.ERRORED),
];
}
return [
{
kind: "unmapped",
eventType: "message_update:error",
reason:
"agent abort (reason=aborted) — not a crash; abort-surfacing staged",
},
];
return [this.#sessionError(SessionErrorKind.ABORTED, inner.error)];
}
default:
// start/text_start/thinking_start/thinking_end/image_end/toolcall_*/
Expand Down
45 changes: 45 additions & 0 deletions packages/compass-agent/src/transport/frame-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
AgentSessionState,
DeliveryAckSchema,
ForgeNotificationAckSchema,
SessionErrorKind,
SessionErrorSchema,
SessionEventSchema,
SessionFrameSchema,
SessionInjectionKind,
Expand Down Expand Up @@ -735,3 +737,46 @@ test("a SessionInjection rides the Publish PRIORITY sub-lane, never the drop-old
// It never touched the loss-tolerable trace lane.
expect(traceFrames.length).toBe(0);
});

test("a SessionError rides the Publish PRIORITY sub-lane, never the drop-oldest trace queue", () => {
// DL-323: a SessionError is a "session" trace frame (state UNSPECIFIED), so by
// the default classification it would ride the bounded, drop-oldest trace lane
// — where a busy trace stream could silently drop the surfaced failure content
// (for a reason=aborted failure, the SOLE signal). isSessionError() pins it
// onto the never-drop priority lane instead. The socket recorder cannot
// distinguish the two Publish sub-lanes, so this spy-spine test is what pins
// the choice. Non-vacuity: drop the `|| isSessionError(frame)` arm in emit()
// (frame-sink.ts) → the priority assertion reddens (0) and the trace assertion
// reddens (2). Both SessionErrorKinds ride the same lane.
for (const kind of [SessionErrorKind.ERROR, SessionErrorKind.ABORTED]) {
const { spine, priorityFrames, traceFrames } = spySpine();
const sink = createSocketFrameSink(spineTransport(spine));
sink.emit({
kind: "session",
value: create(SessionFrameSchema, {
state: AgentSessionState.UNSPECIFIED,
typedEvent: create(SessionEventSchema, {
event: {
case: "sessionError",
value: create(SessionErrorSchema, {
kind,
message: "boom",
}),
},
}),
}),
});
// Exactly one priority frame, carrying the sessionError oneof case + kind.
expect(priorityFrames.length).toBe(1);
const inner = priorityFrames[0]?.frame?.frame;
expect(inner?.case).toBe("session");
const event =
inner?.case === "session" ? inner.value.typedEvent?.event : undefined;
expect(event?.case).toBe("sessionError");
expect(event?.case === "sessionError" ? event.value.kind : undefined).toBe(
kind,
);
// It never touched the loss-tolerable trace lane.
expect(traceFrames.length).toBe(0);
}
});
16 changes: 15 additions & 1 deletion packages/compass-agent/src/transport/frame-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,20 @@ function isInjection(frame: OutboundFrame): boolean {
);
}

// A "session" frame carrying a SessionError trace event is NOT loss-tolerable
// (DL-323): the surfaced failure content is observation-critical — for a
// reason=aborted failure it is the SOLE signal (no lifecycle frame accompanies
// it), and for reason=error the paired ERRORED transition survives on the
// priority lane but the content itself would still vanish off the bounded trace
// queue under backlog. So it rides the never-drop priority lane, matching the
// SessionInjection carve-out, even though its board state is UNSPECIFIED.
function isSessionError(frame: OutboundFrame): boolean {
return (
frame.kind === "session" &&
frame.value.typedEvent?.event.case === "sessionError"
);
}

export function createSocketFrameSink(transport: RunnerTransport): FrameSink {
const spine = transport.publishSpine();
// Borrow the single transport-owned ManagedRuntime through the module-private
Expand Down Expand Up @@ -266,7 +280,7 @@ export function createSocketFrameSink(transport: RunnerTransport): FrameSink {
const request = create(PublishFrameRequestSchema, {
frame: toAgentFrame(frame),
});
if (isLifecycle(frame) || isInjection(frame)) {
if (isLifecycle(frame) || isInjection(frame) || isSessionError(frame)) {
spine.enqueuePriority(request);
} else {
spine.enqueueTrace(request);
Expand Down
Loading