@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.
- package/package.json +4 -3
- package/src/ai/chat/blocks.tsx +162 -180
- package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
- package/src/ai/chat/messages.ts +4 -24
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/turn-layout.ts +2 -2
- package/src/ai/chat/turn-view.tsx +119 -65
- package/src/ai/protocol.ts +9 -4
- package/src/ai/store.ts +20 -13
- package/src/ai/types.ts +2 -1
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/shared/network-address.ts +1 -0
- package/src/styles/effects.css +142 -0
- package/src/styles/utilities-markdown-table.css +5 -1
|
@@ -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
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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[]):
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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 {
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
323
|
+
const item = () => items().get(key);
|
|
303
324
|
const text = () => {
|
|
304
|
-
const value =
|
|
325
|
+
const value = item();
|
|
305
326
|
return value?.kind === "text" ? value.block.text : undefined;
|
|
306
327
|
};
|
|
307
|
-
const
|
|
308
|
-
const value =
|
|
309
|
-
return value?.kind === "
|
|
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()
|
|
314
|
-
|
|
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
|
-
|
|
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.
|
|
435
|
-
const
|
|
436
|
-
|
|
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.
|
|
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"
|
|
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:
|
|
446
|
-
if (state === "failed") return t().receiptFailed({ title:
|
|
447
|
-
return t().receiptNotRun({ title:
|
|
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
|
-
|
|
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
|
|
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(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
504
|
-
<Match when={
|
|
505
|
-
|
|
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={
|
|
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>
|
package/src/ai/protocol.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
793
|
-
* turn
|
|
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
|
|
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(
|
|
844
|
+
SELECT message.id, jsonb_object_agg(decided.call_id, decided.outcome) AS value
|
|
843
845
|
FROM (
|
|
844
|
-
SELECT
|
|
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.
|
|
852
|
-
AND
|
|
853
|
-
|
|
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',
|
|
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' =
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
];
|