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
22 changes: 22 additions & 0 deletions packages/protocol/src/wire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expectTypeOf, test } from "vitest";
import type { WireEvent } from "./index";

test("the protocol index re-exports the turn_start WireEvent arm", () => {
const turnStart: WireEvent = {
type: "turn_start",
data: { provider: "anthropic", model: "claude-sonnet-4-6" },
};

expectTypeOf(turnStart).toMatchTypeOf<WireEvent>();
// `type` is the discriminant → narrows `data` to the turn_start shape.
if (turnStart.type === "turn_start") {
expectTypeOf(turnStart.data).toEqualTypeOf<{
provider: string;
model: string;
}>();
}

// @ts-expect-error — `type` is the discriminant; unknown values are not assignable
const bad: WireEvent = { type: "not_an_event", data: null };
void bad;
});
13 changes: 13 additions & 0 deletions packages/protocol/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { ProviderError } from "./provider-error";
* be served — the client must treat the stream as fresh (refetch history,
* rebuild from this snapshot) instead of splicing.
* - `user` — a user message was added (by any client); `nonce` echoes the sender's.
* - `turn_start` — the provider + resolved model this turn runs against.
* - `text` / `thinking` — assistant output deltas.
* - `tool_start` / `tool_end` — tool activity within the turn.
* - `usage` — normalized token usage for the turn (when the provider reports it),
Expand Down Expand Up @@ -100,6 +101,18 @@ export type WireEvent =
mentions?: { userId: string; name?: string }[];
};
}
| {
/**
* Turn metadata, emitted once at turn start: the provider and the
* concrete model this turn resolved to after applying any per-turn pin
* (not the raw pin, which may be absent = inherit). Lets a client
* attribute a turn's output to a specific provider + model without
* inferring it from a later `provider_switched` / `provider_error` (which
* only fire on a switch or a failure).
*/
type: "turn_start";
data: { provider: string; model: string };
}
| { type: "text"; data: string }
| { type: "thinking"; data: string }
| { type: "tool_start"; data: { name: string; args: unknown } }
Expand Down
8 changes: 5 additions & 3 deletions packages/runtime-client/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,13 @@ export function reduceSnapshot(
// clears the in-flight snapshot — otherwise a late subscriber's `sync`
// would report the turn as still running forever.
return { running: false, partial: "", seq };
case "turn_start":
case "provider_switched":
case "context_compacted":
// Boundary markers (a mid-session provider switch / a proactive context
// compaction), not turn progress — published while a turn is live, so
// leave running/partial untouched.
// Boundary / metadata markers (the turn's provider+model announcement, a
// mid-session provider switch, a proactive context compaction), not turn
// progress — published while a turn is live, so leave running/partial
// untouched.
return {
running: prev.running,
partial: prev.partial,
Expand Down
21 changes: 21 additions & 0 deletions packages/runtime/src/session/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@ export async function runTurn(
return;
}

// turn_start metadata: the provider + concrete model this turn resolves to
// under its pin, for observers that attribute a turn's output to a model.
// Best-effort — getConversation already validated the pin, so this resolves;
// a resolve failure must never block the turn, so it degrades to blank.
const startProvider = pin?.provider ?? activeProvider();
let startModel = "";
try {
startModel =
(resolveModel(pin?.model, pin?.provider) as { id?: string }).id ?? "";
} catch {
/* leave blank — the turn still runs; provider still rides later frames */
}

// Two layers of serialization: per-conversation ordering (conv.queue) AND
// the per-workdir lock — every conversation in this runtime shares ONE
// workspaceDir, so a routine's turn and a user chat queue instead of
Expand Down Expand Up @@ -177,6 +190,14 @@ export async function runTurn(
displayText,
mentions,
);
// turn_start rides right after the user frame (metadata for the live turn),
// matching the cloud executor where the concrete model is only known then.
if (startProvider)
publish(id, {
type: "turn_start",
data: { provider: startProvider, model: startModel },
turnId,
});
return withWorkdirLock(config.workspaceDir, () =>
execTurn(conv, id, turnId, text, recorded, pin, acting),
);
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/turn/turn-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ export async function runPiTurn(
console.log(
`[turn] provider=${provider} model=${m.id} baseUrl=${m.baseUrl}`,
);
// Announce the turn's resolved provider + model to observers (turn_start).
// Emitted here — the first point the concrete model is known (after applying
// the pin) — so it carries the real model id, not the raw pin.
emit({ type: "turn_start", data: { provider, model: m.id ?? "" } });
// Effort → pi's thinking level. The turn's pin (the host bakes the agent's
// saved effort into it) wins; if none and the model can reason, default to
// medium so a "thinking" model actually reasons (pi enables reasoning only
Expand Down