@camox/api-contract 0.54.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/threads.ts CHANGED
@@ -2,6 +2,8 @@ import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
2
2
  import type { AgentEvent, EntryRecord } from "@earendil-works/pi-durable";
3
3
  import { z } from "zod";
4
4
 
5
+ import type { DraftOutcome } from "./drafts.ts";
6
+
5
7
  // A thread with Camox's agent, as the studio shows it. The thread's Durable Object sends Pi's own
6
8
  // agent events over its socket, and this module is the one place that interprets them: the
7
9
  // studio and the API's tests fold them with the same code.
@@ -29,6 +31,41 @@ export const BUILD_STATUS_ENTRY = "camox.build-status";
29
31
  */
30
32
  export const RECONCILE_ENTRY = "camox.reconcile";
31
33
 
34
+ /**
35
+ * The kind of the transcript entry opening a draft's thread a Live thread handed a request off
36
+ * to: the Brief, which the model reads and editors see as from Camox, ahead of the editor's
37
+ * messages.
38
+ */
39
+ export const BRIEF_ENTRY = "camox.brief";
40
+
41
+ /**
42
+ * The kind of the transcript entry telling the agent on Live that a draft it started ended,
43
+ * published or deleted, with the draft's id as data (`{ draftId }`). The model reads
44
+ * it at the end of the conversation; editors never see it, since the draft's card says so.
45
+ */
46
+ export const DRAFT_ENDED_ENTRY = "camox.draft-ended";
47
+
48
+ /**
49
+ * The agent's tool on Live handing a request off to a draft it starts (ADR 0025). A run whose
50
+ * call of it went through ends on the draft's card.
51
+ */
52
+ export const START_DRAFT_TOOL = "startDraft";
53
+
54
+ /** The draft a `startDraft` call started, as its result's details name it for the card. */
55
+ export const startedDraftCardSchema = z.object({ draftId: z.string(), name: z.string() });
56
+ export type StartedDraftCard = z.infer<typeof startedDraftCardSchema>;
57
+
58
+ /**
59
+ * Where a draft a Live thread's card names stands, as the thread looks it up whenever it's shown
60
+ * (ADR 0025): its current name, or its last once it ended, and how it ended. Never written into
61
+ * the transcript, so the model's cached prompt keeps up. A name cleared by a rename is null: the
62
+ * card keeps the name the agent gave it.
63
+ */
64
+ export type DraftCardState = { name: string | null; ended?: DraftOutcome };
65
+
66
+ /** The drafts a Live thread's cards name, by id, as the thread last looked them up. */
67
+ export type DraftCardStates = Readonly<Record<string, DraftCardState>>;
68
+
32
69
  export const reconcileDataSchema = z.object({ message: z.string() });
33
70
 
34
71
  export const buildStatusSchema = z.enum(["building", "fixing"]);
@@ -48,15 +85,19 @@ export type ThreadSender = z.infer<typeof threadSenderSchema>;
48
85
  export const isSender = (data: unknown): data is ThreadSender =>
49
86
  threadSenderSchema.safeParse(data).success;
50
87
 
51
- /** What an editor's message entry records: who sent it, and the operation id it was sent with. */
88
+ /**
89
+ * What an editor's message entry records: who sent it, the operation id it was sent with, and for
90
+ * a message sent on Live as the agent handed the request off, the draft it went to with it.
91
+ */
52
92
  export const editorMessageDataSchema = threadSenderSchema.extend({
53
93
  operationId: z.string().optional(),
94
+ handedOffTo: z.string().optional(),
54
95
  });
55
96
 
56
97
  /**
57
- * A draft a Live thread started with its first write, becoming its thread, and the operation ids
58
- * of the messages of the run that made the write: the studio that sent one of them enters the
59
- * draft with the thread.
98
+ * A draft the agent on Live started with `startDraft`, and the operation ids of the messages of
99
+ * the run that started it: the studio that sent one of them enters the draft, if it still shows
100
+ * Live.
60
101
  */
61
102
  export const startedDraftSchema = z.object({
62
103
  draftId: z.string(),
@@ -119,22 +160,16 @@ export type ThreadMessage =
119
160
  */
120
161
  | { id: string; from: "failed" }
121
162
  /** In place of the agent's message, for a turn an editor stopped: `sender` stopped it. */
122
- | { id: string; from: "stopped"; sender: ThreadSender };
123
-
124
- /** Where a Live thread's messages went when it started a draft, beside its transcript. */
125
- type LiveThreadHandoff = {
163
+ | { id: string; from: "stopped"; sender: ThreadSender }
126
164
  /**
127
- * The draft this Live thread started with its first write, whose thread it became. The studio
128
- * that sent a message of that run enters the draft with the thread; other tabs on Live move to
129
- * the editor's fresh Live thread.
165
+ * In place of the agent's message, for a Live turn handed off to a draft the agent started:
166
+ * the draft's card, with its current name, which opens it. Once the draft ended, it has its
167
+ * last name and how it ended, and opens nothing. The turn is finished.
130
168
  */
131
- startedDraft?: StartedDraft;
132
- /** The draft the editor's previous Live thread continued in, for a fresh Live thread. */
133
- continuedIn?: StartedDraft;
134
- };
169
+ | ({ id: string; from: "draft"; ended?: DraftOutcome } & StartedDraftCard);
135
170
 
136
171
  /** Everything the studio shows for a thread. */
137
- export type ThreadView = LiveThreadHandoff & {
172
+ export type ThreadView = {
138
173
  messages: readonly ThreadMessage[];
139
174
  /** Whether a run is going: the agent is working on a turn, and stopping would end it. */
140
175
  running: boolean;
@@ -157,20 +192,28 @@ export const EMPTY_THREAD_VIEW: ThreadView = { messages: [], running: false };
157
192
  * and whether a run is going. The studio and the tests fold Pi's events into this, and derive
158
193
  * the view from it with `threadViewOf`, so a snapshot and the events that led to it show the same.
159
194
  */
160
- export type ThreadState = LiveThreadHandoff & {
195
+ export type ThreadState = {
161
196
  steps: readonly ThreadStep[];
162
197
  running: boolean;
163
198
  /** The model's calls still running, by call id. A tool's own calls aren't the agent's. */
164
199
  toolCalls?: readonly string[];
165
200
  /** Whether the model's latest message makes tool calls: it writes them, then they run. */
166
201
  callingTools?: boolean;
202
+ /** The drafts the thread's cards name, as the thread last looked them up. */
203
+ drafts?: DraftCardStates;
167
204
  };
168
205
 
169
206
  export const EMPTY_THREAD_STATE: ThreadState = { steps: [], running: false };
170
207
 
171
208
  /** One transcript entry, as far as the studio's view is concerned. */
172
209
  type ThreadStep = { id: string } & (
173
- | { type: "editor"; sender: ThreadSender; text: string }
210
+ | {
211
+ type: "editor";
212
+ sender: ThreadSender;
213
+ text: string;
214
+ /** The draft the message went to, as the agent on Live handed the request off. */
215
+ handedOffTo?: string;
216
+ }
174
217
  /** Camox starting a turn of its own, saying why. */
175
218
  | { type: "camox"; text: string }
176
219
  | { type: "answer"; text: string }
@@ -179,6 +222,8 @@ type ThreadStep = { id: string } & (
179
222
  /** A model call that ended without an answer: failed, aborted, or cut short. */
180
223
  | { type: "unfinished" }
181
224
  | { type: "stopped"; sender: ThreadSender }
225
+ /** A `startDraft` call that went through, which ends the turn on the draft's card. */
226
+ | ({ type: "draft" } & StartedDraftCard)
182
227
  /** Where the build of the turn's code stands, as the turn ends. */
183
228
  | { type: "build"; status: BuildStatus }
184
229
  /**
@@ -211,13 +256,16 @@ export type ThreadServerMessage =
211
256
  | { type: "events"; events: readonly AgentEvent[] }
212
257
  /** The thread recorded the message sent with this operation id (once, however often sent). */
213
258
  | { type: "accepted"; operationId: string }
214
- /** This Live thread started a draft with its first write, and became that draft's thread. */
259
+ /**
260
+ * The agent on Live started a draft with `startDraft`: the studio that sent a message of the
261
+ * run enters it, if it still shows Live. Live only: a snapshot never replays it.
262
+ */
215
263
  | { type: "started-draft"; startedDraft: StartedDraft }
216
264
  /**
217
- * The editor's previous Live thread continued in this draft: this one is fresh. Sent before
218
- * the snapshot.
265
+ * Where the drafts a Live thread's cards name stand, after each snapshot and whenever one of
266
+ * them is renamed or ends. It replaces what the studio knew of them.
219
267
  */
220
- | { type: "continued-in"; continuedIn: StartedDraft }
268
+ | { type: "drafts"; drafts: DraftCardStates }
221
269
  /**
222
270
  * A code-mode script that wrote something ended, failed or not: what its successful writes
223
271
  * touched. Sent once per such script, live only: a snapshot never replays past touches.
@@ -232,7 +280,8 @@ export type ThreadServerMessage =
232
280
  */
233
281
  export const THREAD_ENDED_CLOSE_CODE = 4000;
234
282
 
235
- const textOf = (content: Message["content"]) =>
283
+ /** The text of a message's content: its text parts, joined. */
284
+ export const textOf = (content: Message["content"]) =>
236
285
  typeof content === "string"
237
286
  ? content
238
287
  : content
@@ -264,12 +313,24 @@ function threadStepOf(entry: EntryRecord): ThreadStep | undefined {
264
313
  const build = buildStatusDataSchema.safeParse(entry.data).data;
265
314
  return build && { id, type: "build", status: build.status };
266
315
  }
316
+ // Only the model reads it: as a user message, it would otherwise read as Pi continuing a run.
317
+ if (entry.kind === DRAFT_ENDED_ENTRY) return undefined;
267
318
  const message = entry.model?.find((candidate) => candidate.role !== "system");
268
319
  if (!message) return undefined;
269
320
  if (message.role === "user") {
270
321
  if (entry.kind === EDITOR_MESSAGE_ENTRY) {
271
- return sender && { id, type: "editor", sender, text: textOf(message.content) };
322
+ const handedOffTo = editorMessageDataSchema.safeParse(entry.data).data?.handedOffTo;
323
+ return (
324
+ sender && {
325
+ id,
326
+ type: "editor",
327
+ sender,
328
+ text: textOf(message.content),
329
+ ...(handedOffTo && { handedOffTo }),
330
+ }
331
+ );
272
332
  }
333
+ if (entry.kind === BRIEF_ENTRY) return { id, type: "camox", text: textOf(message.content) };
273
334
  if (entry.kind === RECONCILE_ENTRY) {
274
335
  const reconcile = reconcileDataSchema.safeParse(entry.data).data;
275
336
  return reconcile && { id, type: "camox", text: reconcile.message };
@@ -279,15 +340,21 @@ function threadStepOf(entry: EntryRecord): ThreadStep | undefined {
279
340
  return { id, type: "continued" };
280
341
  }
281
342
  if (message.role === "assistant") return assistantStep(id, message);
282
- if (message.role === "toolResult") return { id, type: "tools" };
343
+ if (message.role === "toolResult") {
344
+ const card =
345
+ message.toolName === START_DRAFT_TOOL && !message.isError
346
+ ? startedDraftCardSchema.safeParse(message.details).data
347
+ : undefined;
348
+ return card ? { id, type: "draft", ...card } : { id, type: "tools" };
349
+ }
283
350
  return undefined;
284
351
  }
285
352
 
286
353
  /**
287
354
  * What the studio shows for a thread. A turn starts with an editor's message, or Camox's own
288
355
  * when it began the turn itself, and messages sent
289
- * while it runs join it. It ends with the agent's final message; text written beside tool calls
290
- * is never shown. A stopped turn shows who stopped it instead, and whatever its run wrote after
356
+ * while it runs join it. It ends with the agent's final message, or on Live, the card of the
357
+ * draft it handed the request off to; text written beside tool calls is never shown. A stopped turn shows who stopped it instead, and whatever its run wrote after
291
358
  * the stop is left out. A turn that ended without either shows a failed line in its place: its
292
359
  * run is over, or a later message started another run after its last model call came back
293
360
  * unfinished. While Pi retries a failed call, the run goes on, so the turn shows as working.
@@ -297,7 +364,7 @@ export function threadViewOf({
297
364
  running,
298
365
  toolCalls = [],
299
366
  callingTools = false,
300
- ...drafts
367
+ drafts = {},
301
368
  }: ThreadState): ThreadView {
302
369
  const messages: ThreadMessage[] = [];
303
370
  // The latest editor's message of a turn still waiting for the agent's message.
@@ -322,7 +389,24 @@ export function threadViewOf({
322
389
 
323
390
  for (const step of steps) {
324
391
  switch (step.type) {
325
- case "editor":
392
+ case "editor": {
393
+ // A message sent as the agent on Live handed the request off went to the draft with it:
394
+ // it belongs to the turn ending on that draft's card, which it shows just before. Pi
395
+ // placed it after the card, which leaves the transcript the model read as it was.
396
+ const card = step.handedOffTo
397
+ ? messages.findLastIndex(
398
+ (message) => message.from === "draft" && message.draftId === step.handedOffTo,
399
+ )
400
+ : -1;
401
+ if (card !== -1) {
402
+ messages.splice(card, 0, {
403
+ id: step.id,
404
+ from: "editor",
405
+ sender: step.sender,
406
+ text: step.text,
407
+ });
408
+ break;
409
+ }
326
410
  // Pi places a steering message only once a model call is settled: after a round of tool
327
411
  // calls, or with the answer ending the run. Never between a failed call and its retry, so
328
412
  // an editor's message after an unfinished call means that call's turn is over.
@@ -330,6 +414,7 @@ export function threadViewOf({
330
414
  messages.push({ id: step.id, from: "editor", sender: step.sender, text: step.text });
331
415
  waiting = step.id;
332
416
  break;
417
+ }
333
418
  case "camox":
334
419
  if (unfinished) fail();
335
420
  messages.push({ id: step.id, from: "camox", text: step.text });
@@ -350,6 +435,21 @@ export function threadViewOf({
350
435
  answered = before;
351
436
  }
352
437
  break;
438
+ case "draft":
439
+ // The handoff answers the turn: the agent says nothing more.
440
+ if (waiting === undefined && messages.at(-1)?.from === "stopped") break;
441
+ {
442
+ const draft = drafts[step.draftId];
443
+ messages.push({
444
+ id: step.id,
445
+ from: "draft",
446
+ draftId: step.draftId,
447
+ name: draft?.name ?? step.name,
448
+ ...(draft?.ended && { ended: draft.ended }),
449
+ });
450
+ }
451
+ endTurn();
452
+ break;
353
453
  case "continued":
354
454
  // The answer before wasn't the turn's last. Once the end of the turn gives up fixing,
355
455
  // the agent explains without a status.
@@ -373,7 +473,6 @@ export function threadViewOf({
373
473
  if (!running) fail();
374
474
  const usingTools = running && (callingTools || toolCalls.length > 0);
375
475
  return {
376
- ...drafts,
377
476
  messages,
378
477
  running,
379
478
  ...(running && status && { status }),
@@ -439,10 +538,8 @@ export function reduceThreadFrame(state: ThreadState, frame: ThreadServerMessage
439
538
  switch (frame.type) {
440
539
  case "events":
441
540
  return reduceThreadEvents(state, frame.events);
442
- case "started-draft":
443
- return { ...state, startedDraft: frame.startedDraft };
444
- case "continued-in":
445
- return { ...state, continuedIn: frame.continuedIn };
541
+ case "drafts":
542
+ return { ...state, drafts: frame.drafts };
446
543
  default:
447
544
  return state;
448
545
  }