@k2b/cloud 0.29.0 → 0.29.1

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.
@@ -1,10 +1,18 @@
1
+ import { aiSkillFilePathFromMount } from "../file-mount";
1
2
  import type { AiTurnBlock } from "../protocol";
2
3
  import { aiChatMessages } from "./messages";
3
4
 
4
5
  type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
5
6
  type Text = Extract<AiTurnBlock, { kind: "text" }>;
6
7
  export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
7
- export type AiWorkGroup = { kind: "text"; block: Text } | { kind: "steps"; id: string; entries: AiWorkEntry[] };
8
+ /**
9
+ * One row of the expanded work, all at one indent: an intermediate text, a step, or a group that folds housekeeping
10
+ * steps and opens in place.
11
+ */
12
+ export type AiWorkItem =
13
+ | { kind: "text"; block: Text }
14
+ | { kind: "step"; entry: AiWorkEntry }
15
+ | { kind: "housekeeping"; id: string; entries: AiWorkEntry[] };
8
16
 
9
17
  /** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
10
18
  export function isFailedTool(tool: Tool): boolean {
@@ -20,46 +28,65 @@ export function isFailedTool(tool: Tool): boolean {
20
28
  );
21
29
  }
22
30
 
31
+ const HOUSEKEEPING_TOOLS = new Set([
32
+ "load_skill",
33
+ "search_skills",
34
+ "load_tools",
35
+ "search_tools",
36
+ "list_apps",
37
+ "search_help",
38
+ "read_help",
39
+ "search_project",
40
+ "read_project_knowledge",
41
+ ]);
42
+
43
+ /** Preparation the reader rarely needs: loading tools, skills, and knowledge, and reading a loaded skill's files. */
44
+ export const isHousekeepingTool = (tool: Tool): boolean => {
45
+ if (HOUSEKEEPING_TOOLS.has(tool.name)) return true;
46
+ if (tool.name !== "read_file" && tool.name !== "list_files") return false;
47
+ const path = tool.args !== null && typeof tool.args === "object" && "path" in tool.args ? tool.args.path : undefined;
48
+ return typeof path === "string" && aiSkillFilePathFromMount(path.trim()) !== null;
49
+ };
50
+
51
+ const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
52
+
23
53
  /**
24
- * Split folded work into intermediate texts and the step groups between them. Reasoning stays inside its group so it
25
- * does not break up the tools around it. A group is keyed by its first tool, whose id is the same live and in history.
54
+ * The rows of the expanded work, in the original order. A run of at least two housekeeping steps, with the reasoning
55
+ * between them, folds into one group, unless it would hold all but one of the work's steps: then the steps show
56
+ * directly, so the work line never opens to a summary of itself. Entries listed in `shown` already show as rows of
57
+ * their own and never fold into a group, so nothing a reader sees folds away under them. A group is keyed by its first
58
+ * tool, whose id is the same live and in history.
26
59
  */
27
- export function groupWorkBlocks(blocks: readonly AiTurnBlock[]): AiWorkGroup[] {
28
- const groups: AiWorkGroup[] = [];
29
- for (const block of blocks) {
30
- if (block.kind === "steer_message") continue;
31
- if (block.kind === "text") {
32
- groups.push({ kind: "text", block });
33
- continue;
60
+ export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
61
+ const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
62
+ const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
63
+ const items: AiWorkItem[] = [];
64
+ let run: AiWorkEntry[] = [];
65
+ let trailing: AiWorkEntry[] = [];
66
+ const flush = () => {
67
+ if (run.length > 0) {
68
+ const runSteps = run.filter(isStep).length;
69
+ if (runSteps >= 2 && steps - runSteps >= 2) items.push({ kind: "housekeeping", id: run[0]!.id, entries: run });
70
+ else for (const entry of run) items.push({ kind: "step", entry });
71
+ }
72
+ // Reasoning after the last housekeeping step belongs to what comes next.
73
+ for (const entry of trailing) items.push({ kind: "step", entry });
74
+ run = [];
75
+ trailing = [];
76
+ };
77
+ for (const entry of entries) {
78
+ const foldable = !shown.has(entry.id);
79
+ if (foldable && entry.kind === "tool" && isHousekeepingTool(entry)) {
80
+ run.push(...trailing, entry);
81
+ trailing = [];
82
+ } else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
83
+ else {
84
+ flush();
85
+ items.push(entry.kind === "text" ? { kind: "text", block: entry } : { kind: "step", entry });
34
86
  }
35
- const last = groups.at(-1);
36
- if (last?.kind === "steps") last.entries.push(block);
37
- else groups.push({ kind: "steps", id: block.id, entries: [block] });
38
- }
39
- for (const group of groups) {
40
- if (group.kind === "steps") group.id = (group.entries.find((entry) => entry.kind === "tool") ?? group.entries[0]!).id;
41
87
  }
42
- return groups;
43
- }
44
-
45
- /** What a group of tools did, in categories; failures and rejections are counted separately. */
46
- export function summarizeToolGroup(tools: readonly Tool[], locale: string): string {
47
- const t = aiChatMessages(locale);
48
- const categories = tools.map((tool) => {
49
- if (tool.name === "todo_write") return t.groupPlan;
50
- if (tool.name === "present" || tool.name === "code_present" || tool.name === "card" || tool.name === "cloud_card")
51
- return t.groupDelivered;
52
- if (tool.name.startsWith("code_")) return t.groupCode;
53
- if (tool.presentation?.kind === "capability" || tool.name === "read_cloud_resource") return t.groupCloud;
54
- if (["read_file", "list_files", "view_image"].includes(tool.name)) return t.groupReadFiles;
55
- if (tool.name === "write_file") return t.groupWriteFiles;
56
- if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return t.groupPdf;
57
- if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(tool.name)) return t.groupLoad;
58
- if (tool.name.startsWith("web_") || tool.name === "fetch_file") return t.groupWeb;
59
- if (tool.name === "memory") return t.groupMemory;
60
- return t.groupTools;
61
- });
62
- return [...new Set(categories)].join(", ");
88
+ flush();
89
+ return items;
63
90
  }
64
91
 
65
92
  /** "9 steps · 1 failed · 1 rejected" for a group of steps. */
@@ -79,9 +79,9 @@ const actionState = (block: ToolBlock, phase: AiTurnPhase): AiTurnActionState |
79
79
  if (block.status === "rejected") return "rejected";
80
80
  if (isSurveyToolName(block.name) || isTextEditorToolName(block.name)) return isFailedTool(block) ? null : "interaction";
81
81
  if (block.name === "code_secret") return block.status === "awaiting_client" ? "interaction" : null;
82
- // Effects in Cloud and decided approvals stay visible as receipts, whether or not the model mentions them.
82
+ // Effects in Cloud and approvals, decided or expired, stay visible as receipts, whether or not the model mentions them.
83
83
  const capabilityAction = block.presentation?.kind === "capability" && block.presentation.capabilityKind === "action";
84
- if (!capabilityAction && !block.approved) return null;
84
+ if (!capabilityAction && block.approved === undefined) return null;
85
85
  if (block.status === "running" || block.status === "awaiting_client") return live(phase) ? "running" : "not_run";
86
86
  return isFailedTool(block) ? "failed" : "done";
87
87
  };
@@ -17,7 +17,14 @@ import {
17
17
  import { markdown } from "../../shared";
18
18
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
19
  import type { AiTurnError } from "../types";
20
- import { ApprovalBlockView, CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
20
+ import {
21
+ type AiApprovalDecision,
22
+ ApprovalBlockView,
23
+ CompactionBlockView,
24
+ CompactToolRow,
25
+ SurveyToolView,
26
+ TextEditorToolView,
27
+ } from "./blocks";
21
28
  import { CapabilityTablePreview } from "./capability-table";
22
29
  import { PresentToolBlock } from "./file-tools";
23
30
  import { useAiChatActions } from "./message-actions";
@@ -25,9 +32,9 @@ import { aiToolIcon, capabilityErrorDescription, displayToolName, isCardToolName
25
32
  import { aiChatMessages } from "./messages";
26
33
  import { AssistantMarkdownBlock } from "./primitives";
27
34
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
28
- import { type AiWorkEntry, countWorkSteps, groupWorkBlocks, summarizeToolGroup } from "./tool-groups";
35
+ import { type AiWorkEntry, type AiWorkItem, countWorkSteps, groupWorkBlocks } from "./tool-groups";
29
36
  import { aiTurnErrorCanContinue, aiTurnErrorDescription } from "./turn-error";
30
- import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
37
+ import type { AiTurnAction, AiTurnActionState, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
31
38
  import { CloudCardBlock } from "./visual-tools";
32
39
 
33
40
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -88,10 +95,12 @@ export const formatWorkClock = (ms: number): string => {
88
95
 
89
96
  const basename = (value: string) => value.slice(value.lastIndexOf("/") + 1) || value;
90
97
 
98
+ /** What a call acts on: the file name of its path, or its name, title, or query as given. */
91
99
  const toolTarget = (block: ToolBlock): string => {
92
100
  const args = isRecord(block.args) ? block.args : {};
93
- const value = [args.path, args.name, args.title, args.query].find((entry) => typeof entry === "string" && entry.trim());
94
- return typeof value === "string" ? basename(value.trim()) : "";
101
+ if (typeof args.path === "string" && args.path.trim()) return basename(args.path.trim());
102
+ const value = [args.name, args.title, args.query].find((entry) => typeof entry === "string" && entry.trim());
103
+ return typeof value === "string" ? value.trim() : "";
95
104
  };
96
105
 
97
106
  /** A step that runs longer than this shows its duration, so a long step does not look like a stalled one. */
@@ -234,6 +243,7 @@ function ThoughtRow(props: { block: Extract<AiTurnBlock, { kind: "thinking" }>;
234
243
  blockId={props.block.id}
235
244
  label={firstLine()}
236
245
  icon="ti ti-bulb"
246
+ bodyInset={false}
237
247
  renderBody={() => <p class="ai-turn-steps__thought">{props.block.text.trim()}</p>}
238
248
  />
239
249
  </Show>
@@ -254,25 +264,26 @@ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
254
264
  );
255
265
  }
256
266
 
257
- function WorkGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
267
+ /** Housekeeping folded into one row at step level. It opens in place: its steps follow at the same indent. */
268
+ function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
258
269
  const locale = useLocale();
259
- const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
260
270
  const last = () => props.entries.at(-1)!;
261
271
  return (
262
- <Show when={props.entries.length > 1} fallback={<WorkEntryRow entry={last()} busy={props.active} />}>
263
- <AiToolActivity
264
- blockId={`group:${props.id}`}
265
- label={tools().length ? summarizeToolGroup(tools(), locale()) : aiChatMessages(locale()).reasoning}
266
- description={countWorkSteps(props.entries, locale())}
267
- icon="ti ti-stack-2"
268
- busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
269
- renderBody={() => (
272
+ <AiToolActivity
273
+ blockId={`group:${props.id}`}
274
+ label={aiChatMessages(locale()).groupLoad}
275
+ description={countWorkSteps(props.entries, locale())}
276
+ icon="ti ti-stack-2"
277
+ bodyInset={false}
278
+ busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
279
+ renderBody={() => (
280
+ <div class="ai-turn-steps">
270
281
  <For each={props.entries}>
271
282
  {(entry, index) => <WorkEntryRow entry={entry} busy={props.active && index() === props.entries.length - 1} />}
272
283
  </For>
273
- )}
274
- />
275
- </Show>
284
+ </div>
285
+ )}
286
+ />
276
287
  );
277
288
  }
278
289
 
@@ -288,34 +299,46 @@ function WorkText(props: { text: string }) {
288
299
  );
289
300
  }
290
301
 
291
- /** Expanded work: intermediate texts as quiet paragraphs, the steps between them as groups. One indent, one size. */
302
+ const workItemKey = (item: AiWorkItem): string =>
303
+ item.kind === "text" ? `text:${item.block.id}` : item.kind === "step" ? `step:${item.entry.id}` : `group:${item.id}`;
304
+
305
+ /**
306
+ * Expanded work, one indent below the work line and one size: intermediate texts as quiet paragraphs, the steps between
307
+ * them directly, and housekeeping folded at step level. A row shown while this list is open never folds away under the
308
+ * reader, also when reasoning or arguments that would fold it arrive later; the next time it opens, it folds like in
309
+ * history.
310
+ */
292
311
  function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
293
- const groups = createMemo(() => groupWorkBlocks(props.blocks));
294
- const keyed = createMemo(
295
- () => new Map(groups().map((group) => [group.kind === "text" ? `text:${group.block.id}` : `steps:${group.id}`, group])),
296
- );
297
- const keys = createMemo(() => [...keyed().keys()]);
312
+ const shown = new Set<string>();
313
+ const items = createMemo(() => {
314
+ const work = groupWorkBlocks(props.blocks, shown);
315
+ for (const item of work) if (item.kind === "step") shown.add(item.entry.id);
316
+ return new Map(work.map((item) => [workItemKey(item), item]));
317
+ });
318
+ const keys = createMemo(() => [...items().keys()]);
298
319
  return (
299
320
  <div class="ai-turn-steps">
300
321
  <For each={keys()}>
301
322
  {(key, index) => {
302
- const group = () => keyed().get(key);
323
+ const item = () => items().get(key);
303
324
  const text = () => {
304
- const value = group();
325
+ const value = item();
305
326
  return value?.kind === "text" ? value.block.text : undefined;
306
327
  };
307
- const steps = () => {
308
- const value = group();
309
- return value?.kind === "steps" ? value : undefined;
328
+ const step = () => {
329
+ const value = item();
330
+ return value?.kind === "step" ? value.entry : undefined;
331
+ };
332
+ const group = () => {
333
+ const value = item();
334
+ return value?.kind === "housekeeping" ? value : undefined;
310
335
  };
336
+ const active = () => props.active && index() === keys().length - 1;
311
337
  return (
312
338
  <Switch>
313
- <Match when={text() !== undefined}>
314
- <WorkText text={text()!} />
315
- </Match>
316
- <Match when={steps()}>
317
- {(value) => <WorkGroup id={value().id} entries={value().entries} active={props.active && index() === keys().length - 1} />}
318
- </Match>
339
+ <Match when={text()}>{(value) => <WorkText text={value()} />}</Match>
340
+ <Match when={step()}>{(entry) => <WorkEntryRow entry={entry()} busy={active()} />}</Match>
341
+ <Match when={group()}>{(value) => <HousekeepingGroup id={value().id} entries={value().entries} active={active()} />}</Match>
319
342
  </Switch>
320
343
  );
321
344
  }}
@@ -428,31 +451,47 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
428
451
  );
429
452
  }
430
453
 
431
- function AiReceipt(props: { action: AiTurnAction; stopped: boolean }) {
454
+ type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
455
+
456
+ /**
457
+ * One line for an action in place 4, the same element while it runs and once it is done, rejected, failed, or not run.
458
+ * It names the action in the reader's language with what it acted on, such as "Rejected: Run code · report.ts", or the
459
+ * application's own summary of what it did.
460
+ */
461
+ function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
432
462
  const locale = useLocale();
433
463
  const t = () => aiChatMessages(locale());
434
- const block = () => props.action.block;
435
- const title = () => block().presentation?.title ?? displayToolName(block().name, locale());
436
- const links = () => (props.action.state === "done" ? resultLinks(block()) : []);
464
+ const block = () => props.block;
465
+ const subject = () => {
466
+ const title = block().presentation?.title ?? displayToolName(block().name, locale());
467
+ const target = toolTarget(block());
468
+ return target ? `${title} · ${target}` : title;
469
+ };
470
+ const links = () => (props.state === "done" ? resultLinks(block()) : []);
437
471
  const label = () => {
438
- const state = props.action.state;
472
+ const state = props.state;
473
+ if (state === "running") return t().receiptRunning({ title: subject() });
439
474
  if (state === "done") {
440
475
  const summary = resultSummary(block());
441
476
  if (summary) return summary;
442
477
  // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
443
- return block().presentation?.kind === "capability" ? t().receiptDone({ title: title() }) : t().receiptApproved({ title: title() });
478
+ return block().presentation?.kind === "capability"
479
+ ? t().receiptDone({ title: subject() })
480
+ : t().receiptApproved({ title: subject() });
444
481
  }
445
- if (state === "rejected") return t().receiptRejected({ title: title() });
446
- if (state === "failed") return t().receiptFailed({ title: title() });
447
- return t().receiptNotRun({ title: title(), stopped: props.stopped });
482
+ if (state === "rejected") return t().receiptRejected({ title: subject() });
483
+ if (state === "failed") return t().receiptFailed({ title: subject() });
484
+ return t().receiptNotRun({ title: subject(), stopped: props.stopped });
448
485
  };
486
+ const declined = () => props.state === "rejected" || props.state === "not_run";
449
487
  return (
450
488
  <Chat.Activity
451
489
  class="ai-turn-receipt"
452
- icon={aiToolIcon(block().name, block().presentation?.appIcon)}
453
- accent={block().presentation?.appAccent}
490
+ icon={declined() ? "ti ti-circle-off" : aiToolIcon(block().name, block().presentation?.appIcon)}
491
+ accent={declined() ? undefined : block().presentation?.appAccent}
454
492
  label={label()}
455
- description={props.action.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
493
+ busy={props.state === "running"}
494
+ description={props.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
456
495
  trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
457
496
  />
458
497
  );
@@ -463,8 +502,26 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
463
502
  let row!: HTMLDivElement;
464
503
  // Set while focus is in this place, including after the focused control disappeared with the decided card.
465
504
  let focusedHere = false;
505
+ // The decision the server accepted for a card here; its receipt replaces the card before the turn reports it. It
506
+ // belongs to the call it answered: code that asks again shows the next request on the same block as a new card.
507
+ const [decided, setDecided] = createSignal<AiApprovalDecision | null>(null);
466
508
  const action = () => props.action();
467
509
  const state = () => action()?.state;
510
+ const decision = () => {
511
+ const current = action();
512
+ const value = decided();
513
+ return current && value?.callId === current.block.callId ? value.approved : null;
514
+ };
515
+ const card = () => {
516
+ const current = action();
517
+ return current?.state === "open" && current.block.status === "awaiting_approval" && decision() === null ? current.block : undefined;
518
+ };
519
+ const receipt = (): ReceiptState | undefined => {
520
+ const current = action();
521
+ if (!current || card()) return undefined;
522
+ if (current.state === "open" && current.block.status === "awaiting_approval") return decision() ? "running" : "rejected";
523
+ return current.state === "open" || current.state === "interaction" ? undefined : current.state;
524
+ };
468
525
  // Screen readers hear a waiting approval once, as one short line instead of the whole card, without moving focus.
469
526
  createEffect(
470
527
  on(state, (next, previous) => {
@@ -477,13 +534,17 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
477
534
  );
478
535
  }),
479
536
  );
480
- // A card the user decided becomes its receipt in place. Focus stays on that place without scrolling.
537
+ // A card the user decided becomes its receipt in place, once, as the direct result of the decision. Focus stays on
538
+ // that place without scrolling.
481
539
  createEffect(
482
- on(state, (next, previous) => {
483
- if (previous !== "open" || next === "open" || !focusedHere || typeof document === "undefined") return;
484
- const focused = document.activeElement;
485
- if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
486
- }),
540
+ on(
541
+ () => card() !== undefined,
542
+ (open, wasOpen) => {
543
+ if (!wasOpen || open || !focusedHere || typeof document === "undefined") return;
544
+ const focused = document.activeElement;
545
+ if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
546
+ },
547
+ ),
487
548
  );
488
549
  return (
489
550
  <div
@@ -500,11 +561,12 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
500
561
  >
501
562
  <Show when={action()}>
502
563
  {(current) => (
503
- <Switch fallback={<AiReceipt action={current()} stopped={props.phase === "stopped"} />}>
504
- <Match when={current().state === "open" && current().block.status === "awaiting_approval"}>
505
- <ApprovalBlockView turnId={props.turnId} block={current().block} />
564
+ <Switch>
565
+ <Match when={card()}>{(block) => <ApprovalBlockView turnId={props.turnId} block={block()} onDecided={setDecided} />}</Match>
566
+ <Match when={receipt()}>
567
+ {(receiptState) => <AiReceipt block={current().block} state={receiptState()} stopped={props.phase === "stopped"} />}
506
568
  </Match>
507
- <Match when={current().state === "open" || current().state === "interaction"}>
569
+ <Match when={true}>
508
570
  <Switch
509
571
  fallback={
510
572
  <Chat.Activity
@@ -522,14 +584,6 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
522
584
  </Match>
523
585
  </Switch>
524
586
  </Match>
525
- <Match when={current().state === "running"}>
526
- <Chat.Activity
527
- label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
528
- icon={aiToolIcon(current().block.name, current().block.presentation?.appIcon)}
529
- accent={current().block.presentation?.appAccent}
530
- busy
531
- />
532
- </Match>
533
587
  </Switch>
534
588
  )}
535
589
  </Show>
@@ -45,7 +45,10 @@ export type AiTurnBlock =
45
45
  isError?: boolean;
46
46
  /** Present while status is awaiting_approval. */
47
47
  approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
48
- /** The user approved this call in the chat; the decided approval stays visible as a receipt. */
48
+ /**
49
+ * `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
50
+ * waited. Either way the approval stays visible as a receipt.
51
+ */
49
52
  approved?: boolean;
50
53
  /** Present for frontend tools. */
51
54
  frontendMode?: AiFrontendToolMode;
@@ -224,7 +227,7 @@ export const buildBlocksFromMessages = (
224
227
  meta?: {
225
228
  steerId?: string;
226
229
  toolPresentations?: Record<string, AiToolPresentation>;
227
- toolOutcomes?: Record<string, "rejected" | "approved">;
230
+ toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
228
231
  } | null;
229
232
  }[],
230
233
  ): AiTurnBlock[] => {
@@ -239,6 +242,7 @@ export const buildBlocksFromMessages = (
239
242
  } else if (block.type === "thinking") {
240
243
  blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
241
244
  } else if (block.type === "tool_call") {
245
+ const outcome = meta?.toolOutcomes?.[block.id];
242
246
  toolIndex.set(block.id, blocks.length);
243
247
  blocks.push({
244
248
  id: toolBlockId(block.id),
@@ -248,8 +252,9 @@ export const buildBlocksFromMessages = (
248
252
  args: block.args,
249
253
  status: "running",
250
254
  presentation: meta?.toolPresentations?.[block.id],
251
- // A turn that ended before an approved call returned records the approval on the call's message.
252
- ...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
255
+ // A turn that ended before an approved call returned records the approval on the call's message, and an
256
+ // approval it left waiting as expired.
257
+ ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
253
258
  });
254
259
  }
255
260
  });
package/src/ai/store.ts CHANGED
@@ -788,9 +788,10 @@ const messageSearchText = (message: Message): string => {
788
788
  * Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
789
789
  * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
790
790
  * never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
791
- * limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
792
- * A failure's reason goes to the turn's last message, also when that is the user's own, so history can say why the
793
- * turn failed and what to do next.
791
+ * limit never looks like a user stop. A call the user approved that never returned keeps its approval in history, and
792
+ * one whose approval still waited records that the approval expired, so history shows it as not run. A failure's
793
+ * reason goes to the turn's last message, also when that is the user's own, so history can say why the turn failed and
794
+ * what to do next.
794
795
  */
795
796
  const recordTurnEnd = async (
796
797
  db: typeof sql,
@@ -829,8 +830,9 @@ const recordTurnEnd = async (
829
830
  )
830
831
  AND (loop_done_reason IS NULL OR loop_done_reason = ${replaces}::text)
831
832
  `;
832
- // An approved call without a result keeps the decision on the message that holds the call. A decision on a custom
833
- // approval belongs to the call that asked for it.
833
+ // An approved call without a result keeps the decision on the message that holds the call; an approval nobody
834
+ // answered is recorded as expired. A custom approval belongs to the call that asked for it, and one approved request
835
+ // of that call outweighs a later one that expired.
834
836
  await db`
835
837
  UPDATE ${db(table)} target
836
838
  SET meta = jsonb_set(
@@ -839,30 +841,35 @@ const recordTurnEnd = async (
839
841
  COALESCE(target.meta->'toolOutcomes', '{}'::jsonb) || outcomes.value
840
842
  )
841
843
  FROM (
842
- SELECT message.id, jsonb_object_agg(approved.call_id, 'approved'::text) AS value
844
+ SELECT message.id, jsonb_object_agg(decided.call_id, decided.outcome) AS value
843
845
  FROM (
844
- SELECT DISTINCT
846
+ SELECT
845
847
  CASE
846
848
  WHEN action.kind = 'custom_approval' THEN COALESCE(substring(action.call_id FROM '^(.*)-approval-[0-9]+$'), action.call_id)
847
849
  ELSE action.call_id
848
- END AS call_id
850
+ END AS call_id,
851
+ CASE WHEN bool_or(action.resolved_event IS NOT NULL) THEN 'approved' ELSE 'expired' END AS outcome
849
852
  FROM ai.pending_actions action
850
853
  WHERE action.turn_id = ${input.turnId}
851
- AND action.resolved_event->>'type' = 'approval_response'
852
- AND action.resolved_event->>'approved' = 'true'
853
- ) approved
854
+ AND action.kind IN ('approval', 'custom_approval')
855
+ AND (
856
+ action.resolved_event IS NULL
857
+ OR (action.resolved_event->>'type' = 'approval_response' AND action.resolved_event->>'approved' = 'true')
858
+ )
859
+ GROUP BY 1
860
+ ) decided
854
861
  JOIN ${db(table)} message
855
862
  ON message.conversation_id = ${input.conversationId}
856
863
  AND message.loop_id = ${input.turnId}::text
857
864
  AND message.role = 'assistant'
858
- AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', approved.call_id))
865
+ AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', decided.call_id))
859
866
  WHERE NOT EXISTS (
860
867
  SELECT 1
861
868
  FROM ${db(table)} result
862
869
  WHERE result.conversation_id = ${input.conversationId}
863
870
  AND result.loop_id = ${input.turnId}::text
864
871
  AND result.role = 'tool_result'
865
- AND result.message->>'callId' = approved.call_id
872
+ AND result.message->>'callId' = decided.call_id
866
873
  )
867
874
  GROUP BY message.id
868
875
  ) outcomes
package/src/ai/types.ts CHANGED
@@ -300,7 +300,8 @@ export type AiStoredMessage = {
300
300
  trigger: "scheduled" | "manual";
301
301
  };
302
302
  toolPresentations?: Record<string, AiToolPresentation>;
303
- toolOutcomes?: Record<string, "rejected" | "approved">;
303
+ /** How the person decided an approval; `expired` when the turn ended while it still waited. */
304
+ toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
304
305
  /** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
305
306
  turnError?: AiTurnError;
306
307
  } | null;
@@ -3,8 +3,11 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
3
3
  import { describeRoute, validator } from "hono-openapi";
4
4
  import { z } from "zod";
5
5
  import {
6
+ AdminMailAppSchema,
6
7
  AdminMailRecordSchema,
7
8
  MailAppAccessSchema,
9
+ MailLogAccessSchema,
10
+ MailLogAppIdSchema,
8
11
  MailProfileInputSchema,
9
12
  MailProfileKeySchema,
10
13
  MailRetentionSchema,
@@ -235,6 +238,21 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
235
238
  await outgoingMailTest.send(c.req.param("key")!, c.req.valid("json").recipient, context(c));
236
239
  return c.json({ ok: true as const });
237
240
  })
241
+ .put(
242
+ "/apps/:appId/log-access",
243
+ describeRoute({
244
+ summary: "Set application mail log access",
245
+ tags: ["Outgoing mail"],
246
+ ...requiresAdmin,
247
+ responses: {
248
+ 200: jsonResponse(AdminMailAppSchema, "Updated application access"),
249
+ 400: jsonResponse(z.object({ code: z.literal("bad_input"), message: z.string() }), "Invalid log access"),
250
+ },
251
+ }),
252
+ validate("param", z.object({ appId: MailLogAppIdSchema }), "bad_input"),
253
+ validate("json", MailLogAccessSchema, "bad_input"),
254
+ async (c) => c.json(await outgoingMailStore.setAppLogAccess(c.req.valid("param").appId, c.req.valid("json"), context(c))),
255
+ )
238
256
  .get("/apps", async (c) => c.json(await outgoingMailStore.apps()))
239
257
  .put("/apps/:appId", validate("json", MailAppAccessSchema), async (c) =>
240
258
  c.json(await outgoingMailStore.setAppAccess(c.req.param("appId")!, c.req.valid("json"), context(c))),
@@ -2,6 +2,8 @@ import {
2
2
  type AdminMailApp,
3
3
  type AdminMailProfile,
4
4
  type AdminMailRecord,
5
+ MailLogAccessSchema,
6
+ MailLogAppIdSchema,
5
7
  type MailPage,
6
8
  MailProfileInputSchema,
7
9
  type MailRetention,
@@ -181,8 +183,16 @@ export const outgoingMailCommands = [
181
183
  printJsonOrTable(
182
184
  ctx,
183
185
  result,
184
- result.items.map((app) => ({ ...app, profiles: app.profiles.join(",") })),
185
- [{ key: "appId" }, { key: "name" }, { key: "declared" }, { key: "mode" }, { key: "profiles" }],
186
+ result.items.map((app) => ({ ...app, reads: app.readDeclared, profiles: app.profiles.join(","), logApps: app.logApps.join(",") })),
187
+ [
188
+ { key: "appId" },
189
+ { key: "name" },
190
+ { key: "declared" },
191
+ { key: "reads" },
192
+ { key: "mode" },
193
+ { key: "profiles" },
194
+ { key: "logApps" },
195
+ ],
186
196
  );
187
197
  },
188
198
  }),
@@ -212,4 +222,22 @@ export const outgoingMailCommands = [
212
222
  );
213
223
  },
214
224
  }),
225
+ command("outgoing-mail apps set-log-access", {
226
+ summary: "Choose which other applications' mail logs an app may read",
227
+ args: { app: arg.required({ valueLabel: "app" }) },
228
+ flags: {
229
+ apps: flag.string({ description: "Comma-separated source application IDs" }),
230
+ ownOnly: flag.boolean({ description: "Remove all other application log grants" }),
231
+ yes: confirmFlag("Confirm application mail log access change"),
232
+ },
233
+ async run({ ctx, args, flags }) {
234
+ confirmed(flags.yes);
235
+ if ((flags.apps !== undefined) === !!flags.ownOnly) throw new Error("Choose exactly one of --apps or --own-only.");
236
+ const reader = MailLogAppIdSchema.safeParse(args.app);
237
+ const value = MailLogAccessSchema.safeParse({ apps: flags.apps?.split(",").map((app) => app.trim()) ?? [] });
238
+ if (!reader.success || args.app === "core" || !value.success || value.data.apps.includes(args.app))
239
+ throw new Error("Choose a valid reader and distinct source apps other than Core or the reader itself.");
240
+ print(ctx, await apiJson(ctx, "PUT", `${root}/apps/${encodeURIComponent(args.app)}/log-access`, value.data));
241
+ },
242
+ }),
215
243
  ];