@frockbot/plugin-shell 0.3.1 → 0.3.3
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 +32 -29
- package/src/agent.test.ts +15 -3
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +66 -27
- package/src/backend-completion.ts +4 -2
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +567 -13
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +195 -21
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +221 -7
- package/src/client/index.ts +398 -6
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +118 -1
- package/src/composition-views.ts +31 -6
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +70 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative Package entries.
|
|
3
|
+
*
|
|
4
|
+
* An entry is manifest data — an id, a label, an icon name, and the page it
|
|
5
|
+
* opens — so a Package puts a control in the shell's sidebar without shipping
|
|
6
|
+
* a line of JavaScript into the app origin. This module turns the catalog into
|
|
7
|
+
* the ordered list the shell registers, and nothing here executes Package
|
|
8
|
+
* code.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
PackageIframeCatalogV1,
|
|
12
|
+
PackageIframeContributionViewV1,
|
|
13
|
+
PackageIframeEntryViewV1,
|
|
14
|
+
PackageIframePageViewV1,
|
|
15
|
+
} from "@frockbot/kernel-contracts";
|
|
16
|
+
|
|
17
|
+
export interface PackageIframeEntryV1 {
|
|
18
|
+
contribution: PackageIframeContributionViewV1;
|
|
19
|
+
entry: PackageIframeEntryViewV1;
|
|
20
|
+
page: PackageIframePageViewV1;
|
|
21
|
+
/** The slot the page is mounted in when the entry opens it. */
|
|
22
|
+
slot: string;
|
|
23
|
+
/** The shell surface id this entry opens. Stable for one Package page. */
|
|
24
|
+
surfaceId: string;
|
|
25
|
+
/** Where the entry sits among the slot's other fillers. */
|
|
26
|
+
order: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The order a Package entry takes when its manifest names none. */
|
|
30
|
+
export const PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1 = 50;
|
|
31
|
+
|
|
32
|
+
export function packageIframeSurfaceIdV1(
|
|
33
|
+
packageId: string,
|
|
34
|
+
pageId: string,
|
|
35
|
+
): string {
|
|
36
|
+
return `package-page:${packageId}:${pageId}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function packageIframePageSlotV1(pageId: string): string {
|
|
40
|
+
return `frockbot.surface:${pageId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every entry the Bot's active Composition declares, in the order the sidebar
|
|
45
|
+
* draws them. Ties break on Package id so two Packages asking for the same
|
|
46
|
+
* order draw in a stable sequence rather than in catalog order.
|
|
47
|
+
*/
|
|
48
|
+
export function packageIframeEntriesV1(
|
|
49
|
+
catalog: PackageIframeCatalogV1 | undefined,
|
|
50
|
+
): PackageIframeEntryV1[] {
|
|
51
|
+
return (catalog?.contributions ?? [])
|
|
52
|
+
.flatMap((contribution) =>
|
|
53
|
+
contribution.entries.flatMap((entry) => {
|
|
54
|
+
const page = contribution.pages.find(
|
|
55
|
+
(candidate) => candidate.id === entry.opens.page,
|
|
56
|
+
);
|
|
57
|
+
// The catalog decoder already refuses an entry whose page does not
|
|
58
|
+
// mount its own surface slot; this keeps the projection total anyway,
|
|
59
|
+
// because a shell that renders half an entry is worse than one that
|
|
60
|
+
// renders none.
|
|
61
|
+
if (!page) return [];
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
contribution,
|
|
65
|
+
entry,
|
|
66
|
+
page,
|
|
67
|
+
slot: packageIframePageSlotV1(page.id),
|
|
68
|
+
surfaceId: packageIframeSurfaceIdV1(
|
|
69
|
+
contribution.packageId,
|
|
70
|
+
page.id,
|
|
71
|
+
),
|
|
72
|
+
order: entry.order ?? PACKAGE_IFRAME_ENTRY_DEFAULT_ORDER_V1,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
.toSorted(
|
|
78
|
+
(left, right) =>
|
|
79
|
+
left.order - right.order ||
|
|
80
|
+
left.contribution.packageId.localeCompare(
|
|
81
|
+
right.contribution.packageId,
|
|
82
|
+
) ||
|
|
83
|
+
left.entry.id.localeCompare(right.entry.id),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The pages mounted in one slot, in mount order. The right panel and the
|
|
89
|
+
* settings screen both read their pages this way.
|
|
90
|
+
*/
|
|
91
|
+
export function packageIframePagesForSlotV1(
|
|
92
|
+
catalog: PackageIframeCatalogV1 | undefined,
|
|
93
|
+
slot: string,
|
|
94
|
+
): Array<{
|
|
95
|
+
contribution: PackageIframeContributionViewV1;
|
|
96
|
+
page: PackageIframePageViewV1;
|
|
97
|
+
order: number;
|
|
98
|
+
}> {
|
|
99
|
+
return (catalog?.contributions ?? [])
|
|
100
|
+
.flatMap((contribution) =>
|
|
101
|
+
contribution.pages.flatMap((page) =>
|
|
102
|
+
page.mounts
|
|
103
|
+
.filter((mount) => mount.slot === slot)
|
|
104
|
+
.map((mount) => ({ contribution, page, order: mount.order ?? 0 })),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
.toSorted(
|
|
108
|
+
(left, right) =>
|
|
109
|
+
left.order - right.order ||
|
|
110
|
+
left.contribution.packageId.localeCompare(right.contribution.packageId),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PackageIframeHostMessageV2 } from "@frockbot/kernel-contracts";
|
|
3
3
|
import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
|
|
4
4
|
|
|
5
5
|
describe("Package iframe host messages", () => {
|
|
6
6
|
test("posts unchanged state once and posts changed state again", () => {
|
|
7
|
-
const posted:
|
|
7
|
+
const posted: PackageIframeHostMessageV2[] = [];
|
|
8
8
|
const target = {
|
|
9
|
-
postMessage(message:
|
|
9
|
+
postMessage(message: PackageIframeHostMessageV2): void {
|
|
10
10
|
posted.push(message);
|
|
11
11
|
},
|
|
12
12
|
} as Pick<Window, "postMessage">;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PackageIframeHostMessageV2 } from "@frockbot/kernel-contracts";
|
|
2
2
|
|
|
3
3
|
type MessageTarget = Pick<Window, "postMessage">;
|
|
4
4
|
|
|
5
5
|
export function postPackageIframeHostMessage(
|
|
6
6
|
target: MessageTarget,
|
|
7
|
-
message:
|
|
7
|
+
message: PackageIframeHostMessageV2,
|
|
8
8
|
lastStateWireByName: Map<string, string>,
|
|
9
9
|
): void {
|
|
10
10
|
const wire = JSON.stringify(message);
|
|
@@ -20,7 +20,7 @@ export function postPackageIframeHostMessage(
|
|
|
20
20
|
|
|
21
21
|
// Vue settings values may be reactive proxies, which structured clone
|
|
22
22
|
// rejects. JSON is also the bridge's declared value domain.
|
|
23
|
-
target.postMessage(JSON.parse(wire) as
|
|
23
|
+
target.postMessage(JSON.parse(wire) as PackageIframeHostMessageV2, "*");
|
|
24
24
|
if (message.type === "state") {
|
|
25
25
|
lastStateWireByName.set(message.name, wire);
|
|
26
26
|
}
|
package/src/client/styles.css
CHANGED
|
@@ -49,6 +49,15 @@
|
|
|
49
49
|
--panel-current: var(--panel-surface-width);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/*
|
|
53
|
+
* An Applet needs room a summary column does not have, and how much is the
|
|
54
|
+
* User's: `--applet-panel-width` is the width they last dragged, written by
|
|
55
|
+
* the component and remembered per browser.
|
|
56
|
+
*/
|
|
57
|
+
.app-shell.panel-open.panel-applet {
|
|
58
|
+
--panel-current: var(--applet-panel-width, 480px);
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
/* Sidebar */
|
|
53
62
|
|
|
54
63
|
.sidebar {
|
|
@@ -254,6 +263,17 @@
|
|
|
254
263
|
margin-top: 18px;
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
/*
|
|
267
|
+
* A message the User sent while the Bot was still working. It is an ordinary
|
|
268
|
+
* message the Bot has not reached yet, so it is drawn as one and simply held
|
|
269
|
+
* back — no label, no icon, no word for the User to learn. It goes to full
|
|
270
|
+
* strength the moment its own Turn starts.
|
|
271
|
+
*/
|
|
272
|
+
.message-pending {
|
|
273
|
+
opacity: 0.55;
|
|
274
|
+
transition: opacity var(--frock-motion-enter);
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
/* A Session announcement: the conversation narrating itself, not a party. */
|
|
258
278
|
.message-system {
|
|
259
279
|
align-items: center;
|
|
@@ -577,6 +597,44 @@
|
|
|
577
597
|
* message being composed, not a separate control.
|
|
578
598
|
*/
|
|
579
599
|
|
|
600
|
+
/*
|
|
601
|
+
* The focused Applet, on a phone, while its panel is closed.
|
|
602
|
+
*
|
|
603
|
+
* It sits above the composer rather than inside it: the composer's row is the
|
|
604
|
+
* message and the send control, and squeezing a third thing into it is what
|
|
605
|
+
* makes a phone composer unusable.
|
|
606
|
+
*/
|
|
607
|
+
.applet-chip {
|
|
608
|
+
position: absolute;
|
|
609
|
+
bottom: calc(100% + 8px);
|
|
610
|
+
left: 0;
|
|
611
|
+
display: inline-flex;
|
|
612
|
+
max-width: 100%;
|
|
613
|
+
height: var(--frock-control-md);
|
|
614
|
+
align-items: center;
|
|
615
|
+
gap: 6px;
|
|
616
|
+
padding: 0 10px;
|
|
617
|
+
border: 1px solid var(--frock-border-strong);
|
|
618
|
+
border-radius: 999px;
|
|
619
|
+
color: var(--frock-text);
|
|
620
|
+
background: var(--frock-surface-raised);
|
|
621
|
+
box-shadow: var(--frock-shadow-control);
|
|
622
|
+
font-size: var(--frock-text-sm);
|
|
623
|
+
cursor: pointer;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.applet-chip-name {
|
|
627
|
+
overflow: hidden;
|
|
628
|
+
white-space: nowrap;
|
|
629
|
+
text-overflow: ellipsis;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.applet-chip-action {
|
|
633
|
+
flex: 0 0 auto;
|
|
634
|
+
color: var(--frock-action-primary);
|
|
635
|
+
font-weight: 600;
|
|
636
|
+
}
|
|
637
|
+
|
|
580
638
|
.composer-body {
|
|
581
639
|
display: flex;
|
|
582
640
|
width: 100%;
|
|
@@ -702,6 +760,50 @@
|
|
|
702
760
|
flex-direction: column;
|
|
703
761
|
}
|
|
704
762
|
|
|
763
|
+
.panel-applet .right-panel-content {
|
|
764
|
+
width: var(--applet-panel-width, 480px);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/*
|
|
768
|
+
* The Applet panel's drag edge. It is 8px of grab area over a 1px seam, it
|
|
769
|
+
* takes focus, and the arrow keys resize by the same amounts the drag does, so
|
|
770
|
+
* the width is not a mouse-only setting.
|
|
771
|
+
*/
|
|
772
|
+
.applet-panel-handle {
|
|
773
|
+
position: absolute;
|
|
774
|
+
z-index: 16;
|
|
775
|
+
top: 0;
|
|
776
|
+
bottom: 0;
|
|
777
|
+
left: -4px;
|
|
778
|
+
width: 8px;
|
|
779
|
+
border: 0;
|
|
780
|
+
padding: 0;
|
|
781
|
+
background: transparent;
|
|
782
|
+
cursor: col-resize;
|
|
783
|
+
touch-action: none;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.applet-panel-handle::after {
|
|
787
|
+
position: absolute;
|
|
788
|
+
top: 0;
|
|
789
|
+
bottom: 0;
|
|
790
|
+
left: 3px;
|
|
791
|
+
width: 2px;
|
|
792
|
+
background: transparent;
|
|
793
|
+
content: "";
|
|
794
|
+
transition: background-color var(--frock-motion-fast);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.applet-panel-handle:hover::after,
|
|
798
|
+
.applet-panel-handle:focus-visible::after {
|
|
799
|
+
background: var(--frock-action-primary);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.applet-panel-handle:focus-visible {
|
|
803
|
+
outline: 2px solid var(--frock-border-focus);
|
|
804
|
+
outline-offset: -2px;
|
|
805
|
+
}
|
|
806
|
+
|
|
705
807
|
.right-panel-header {
|
|
706
808
|
display: flex;
|
|
707
809
|
height: var(--frock-titlebar-height);
|
|
@@ -788,6 +890,11 @@
|
|
|
788
890
|
width: var(--panel-surface-width);
|
|
789
891
|
}
|
|
790
892
|
|
|
893
|
+
.app-shell.panel-applet .right-panel {
|
|
894
|
+
width: var(--applet-panel-width, 480px);
|
|
895
|
+
max-width: 100%;
|
|
896
|
+
}
|
|
897
|
+
|
|
791
898
|
/*
|
|
792
899
|
* A parked panel is hidden as well as off-canvas: it is not something to
|
|
793
900
|
* tab into, read out, or hit-test, and `visibility` is the one property that
|
|
@@ -894,15 +1001,25 @@
|
|
|
894
1001
|
|
|
895
1002
|
/* The right panel is a drawer too, and on a phone it takes the whole width. */
|
|
896
1003
|
.right-panel,
|
|
897
|
-
.app-shell.panel-surface .right-panel
|
|
1004
|
+
.app-shell.panel-surface .right-panel,
|
|
1005
|
+
.app-shell.panel-applet .right-panel {
|
|
898
1006
|
width: 100%;
|
|
899
1007
|
}
|
|
900
1008
|
|
|
901
1009
|
.right-panel-content,
|
|
1010
|
+
.panel-applet .right-panel-content,
|
|
902
1011
|
.panel-surface-view {
|
|
903
1012
|
width: 100%;
|
|
904
1013
|
}
|
|
905
1014
|
|
|
1015
|
+
/*
|
|
1016
|
+
* A focused Applet on a phone is a full-height sheet, not a wider column:
|
|
1017
|
+
* there is one column, so the canvas is the whole of it while it is open.
|
|
1018
|
+
*/
|
|
1019
|
+
.app-shell.panel-open.panel-applet {
|
|
1020
|
+
--applet-panel-width: 100%;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
906
1023
|
/* There is no window chrome to drag and no traffic lights to clear. */
|
|
907
1024
|
.brand {
|
|
908
1025
|
padding-left: 0;
|
package/src/composition-views.ts
CHANGED
|
@@ -39,7 +39,26 @@ export type CompositionMemberManifestReaderV1 = (
|
|
|
39
39
|
member: CompositionMemberV1,
|
|
40
40
|
) => Promise<FrockBotManifest | undefined>;
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/** What the shell attributes an iframe page to, by where its code came from. */
|
|
43
|
+
function iframeProvenanceV1(
|
|
44
|
+
member: CompositionMemberV1,
|
|
45
|
+
): PackageIframeCompositionV1["contributions"][number]["provenance"] {
|
|
46
|
+
if (member.provenance.kind === "bot") return "Bot-authored";
|
|
47
|
+
if (member.provenance.kind === "first-party") return "FrockBot";
|
|
48
|
+
return "User-installed";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Project iframe Contributions from every artifact-backed member.
|
|
53
|
+
*
|
|
54
|
+
* The test is the artifact, not the provenance. "Every Contribution kind is
|
|
55
|
+
* resolved from the manifest and an artifact, never from a switch over Package
|
|
56
|
+
* identity" — a first-party Package that ships as an artifact-backed member
|
|
57
|
+
* (ADR 0022 decision 8) has a manifest the Bot object holds and pages the
|
|
58
|
+
* anonymous origin can serve, so there is nothing left for a provenance test
|
|
59
|
+
* to decide. A first-party member with no artifact is in-process code whose
|
|
60
|
+
* client Contribution is a compiled module, and it has no iframe page to show.
|
|
61
|
+
*/
|
|
43
62
|
export async function projectPackageIframeCompositionV1(input: {
|
|
44
63
|
botId: string;
|
|
45
64
|
generation: CompositionGenerationV1;
|
|
@@ -47,7 +66,7 @@ export async function projectPackageIframeCompositionV1(input: {
|
|
|
47
66
|
}): Promise<PackageIframeCompositionV1> {
|
|
48
67
|
const contributions: PackageIframeCompositionV1["contributions"] = [];
|
|
49
68
|
for (const member of input.generation.members) {
|
|
50
|
-
if (member.
|
|
69
|
+
if (member.artifact === undefined) continue;
|
|
51
70
|
const manifest = await input.readMemberManifest(member);
|
|
52
71
|
if (!manifest) continue;
|
|
53
72
|
const client = manifest.contributions.client;
|
|
@@ -55,10 +74,16 @@ export async function projectPackageIframeCompositionV1(input: {
|
|
|
55
74
|
contributions.push({
|
|
56
75
|
packageId: member.packageId,
|
|
57
76
|
displayName: manifest.displayName,
|
|
58
|
-
provenance:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
provenance: iframeProvenanceV1(member),
|
|
78
|
+
pages: client.pages.map((page) => ({
|
|
79
|
+
id: page.id,
|
|
80
|
+
artifact: { ...page.artifact },
|
|
81
|
+
mounts: page.mounts.map((mount) => ({ ...mount })),
|
|
82
|
+
})),
|
|
83
|
+
entries: (client.entries ?? []).map((entry) => ({
|
|
84
|
+
...entry,
|
|
85
|
+
opens: { ...entry.opens },
|
|
86
|
+
})),
|
|
62
87
|
declaredTools: (manifest.tools ?? []).map((tool) => tool.name),
|
|
63
88
|
});
|
|
64
89
|
}
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -416,6 +416,46 @@ describe("client run protocol v1", () => {
|
|
|
416
416
|
}),
|
|
417
417
|
).toThrow("turn command.schemaVersion is invalid");
|
|
418
418
|
|
|
419
|
+
// Supersede intent is carried by the field's presence. The composer sends
|
|
420
|
+
// it on every send, and names a run only when it observed one.
|
|
421
|
+
expect(
|
|
422
|
+
decodeClientTurnCommandV1({
|
|
423
|
+
schemaVersion: 1,
|
|
424
|
+
commandId: "command-1",
|
|
425
|
+
text: "hello",
|
|
426
|
+
supersedes: {},
|
|
427
|
+
}),
|
|
428
|
+
).toEqual({
|
|
429
|
+
schemaVersion: 1,
|
|
430
|
+
commandId: "command-1",
|
|
431
|
+
text: "hello",
|
|
432
|
+
supersedes: {},
|
|
433
|
+
});
|
|
434
|
+
expect(
|
|
435
|
+
decodeClientTurnCommandV1({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
commandId: "command-1",
|
|
438
|
+
text: "hello",
|
|
439
|
+
supersedes: { runId: "run-1" },
|
|
440
|
+
}).supersedes,
|
|
441
|
+
).toEqual({ runId: "run-1" });
|
|
442
|
+
expect(() =>
|
|
443
|
+
decodeClientTurnCommandV1({
|
|
444
|
+
schemaVersion: 1,
|
|
445
|
+
commandId: "command-1",
|
|
446
|
+
text: "hello",
|
|
447
|
+
supersedes: { runId: "not a run id" },
|
|
448
|
+
}),
|
|
449
|
+
).toThrow("turn command.supersedes.runId is invalid");
|
|
450
|
+
expect(() =>
|
|
451
|
+
decodeClientTurnCommandV1({
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
commandId: "command-1",
|
|
454
|
+
text: "hello",
|
|
455
|
+
supersedes: { runId: "run-1", extra: 1 },
|
|
456
|
+
}),
|
|
457
|
+
).toThrow();
|
|
458
|
+
|
|
419
459
|
expect(
|
|
420
460
|
decodeClientNotificationAcknowledgementCommandV1({
|
|
421
461
|
schemaVersion: 1,
|
|
@@ -571,6 +611,58 @@ describe("client run protocol v1", () => {
|
|
|
571
611
|
}
|
|
572
612
|
});
|
|
573
613
|
|
|
614
|
+
test("projects dynamic call identity so the client can show the inner tool", () => {
|
|
615
|
+
const projected = projectClientTurnV1({
|
|
616
|
+
runId: "run-dynamic-tool",
|
|
617
|
+
text: "",
|
|
618
|
+
events: [
|
|
619
|
+
event({
|
|
620
|
+
type: "tool/call",
|
|
621
|
+
seq: 0,
|
|
622
|
+
timestamp,
|
|
623
|
+
turn: 1,
|
|
624
|
+
step: 1,
|
|
625
|
+
occurrenceId: "tool:1:1:0",
|
|
626
|
+
name: "call_dynamic_tool",
|
|
627
|
+
input: {
|
|
628
|
+
namespace: "user-Github--acme",
|
|
629
|
+
toolName: "search_issues",
|
|
630
|
+
arguments: { query: "is:open" },
|
|
631
|
+
mcpDetails: { description: "Find open issues." },
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
event({
|
|
635
|
+
type: "tool/result",
|
|
636
|
+
seq: 1,
|
|
637
|
+
timestamp,
|
|
638
|
+
turn: 1,
|
|
639
|
+
step: 1,
|
|
640
|
+
occurrenceId: "tool:1:1:0",
|
|
641
|
+
name: "call_dynamic_tool",
|
|
642
|
+
content: "[]",
|
|
643
|
+
isError: false,
|
|
644
|
+
status: "completed",
|
|
645
|
+
}),
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
expect(projected.events[0]).toEqual({
|
|
650
|
+
type: "tool/call",
|
|
651
|
+
call: {
|
|
652
|
+
id: "tool-1",
|
|
653
|
+
name: "call_dynamic_tool",
|
|
654
|
+
input: {
|
|
655
|
+
namespace: "user-Github--acme",
|
|
656
|
+
toolName: "search_issues",
|
|
657
|
+
argumentsJson: '{"query":"is:open"}',
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
expect(decodeClientTurnV1(structuredClone(projected)).events[0]).toEqual(
|
|
662
|
+
projected.events[0],
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
574
666
|
test("projects only bounded user-visible run state", () => {
|
|
575
667
|
const stored = {
|
|
576
668
|
runId: "run-1",
|