@frockbot/plugin-shell 0.3.2 → 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 +31 -31
- package/src/agent.test.ts +15 -3
- package/src/backend-authoring.ts +14 -1
- package/src/backend-completion.ts +4 -2
- 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 +59 -8
- package/src/client/FrockBotApp.vue +26 -6
- package/src/client/index.test.ts +208 -0
- package/src/client/index.ts +90 -5
- package/src/client/styles.css +11 -0
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +18 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -0
package/src/client/index.test.ts
CHANGED
|
@@ -974,6 +974,52 @@ describe("Bot selection", () => {
|
|
|
974
974
|
});
|
|
975
975
|
|
|
976
976
|
describe("detached Turn projection", () => {
|
|
977
|
+
test("shows a dynamic call as its namespace/tool with inner arguments", () => {
|
|
978
|
+
const messages: Parameters<typeof projectCompletedRuns>[0] = [];
|
|
979
|
+
projectCompletedRuns(
|
|
980
|
+
messages,
|
|
981
|
+
[],
|
|
982
|
+
[
|
|
983
|
+
{
|
|
984
|
+
runId: "run-dynamic",
|
|
985
|
+
input: "Find open issues",
|
|
986
|
+
status: "completed",
|
|
987
|
+
responseText: "Done",
|
|
988
|
+
events: [
|
|
989
|
+
{
|
|
990
|
+
type: "tool/call",
|
|
991
|
+
call: {
|
|
992
|
+
id: "tool-1",
|
|
993
|
+
name: "call_dynamic_tool",
|
|
994
|
+
input: {
|
|
995
|
+
namespace: "user-Github--acme",
|
|
996
|
+
toolName: "search_issues",
|
|
997
|
+
argumentsJson: '{"query":"is:open"}',
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
type: "tool/result",
|
|
1003
|
+
callId: "tool-1",
|
|
1004
|
+
content: "[]",
|
|
1005
|
+
isError: false,
|
|
1006
|
+
},
|
|
1007
|
+
],
|
|
1008
|
+
},
|
|
1009
|
+
],
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
expect(messages[1]?.tools).toEqual([
|
|
1013
|
+
{
|
|
1014
|
+
id: "tool-1",
|
|
1015
|
+
name: "user-Github--acme/search_issues",
|
|
1016
|
+
input: { query: "is:open" },
|
|
1017
|
+
status: "completed",
|
|
1018
|
+
text: "[]",
|
|
1019
|
+
},
|
|
1020
|
+
]);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
977
1023
|
test("projects a completed run before it can be acknowledged", () => {
|
|
978
1024
|
const messages: Parameters<typeof projectCompletedRuns>[0] = [];
|
|
979
1025
|
const projected = projectCompletedRuns(
|
|
@@ -2651,3 +2697,165 @@ describe("Connection operation reconciliation", () => {
|
|
|
2651
2697
|
expect(commandIds[1]).not.toBe(commandIds[0]);
|
|
2652
2698
|
});
|
|
2653
2699
|
});
|
|
2700
|
+
|
|
2701
|
+
describe("a message sent while a Turn is running", () => {
|
|
2702
|
+
test("is accepted, and carries the intent to supersede what is running", async () => {
|
|
2703
|
+
Object.defineProperty(globalThis, "window", {
|
|
2704
|
+
configurable: true,
|
|
2705
|
+
value: {
|
|
2706
|
+
location: { href: "https://app.example/?bot=primary" },
|
|
2707
|
+
history: { replaceState: () => undefined },
|
|
2708
|
+
},
|
|
2709
|
+
});
|
|
2710
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2711
|
+
const sent: {
|
|
2712
|
+
text: string;
|
|
2713
|
+
supersedes?: { runId?: string };
|
|
2714
|
+
}[] = [];
|
|
2715
|
+
let releaseFirst!: () => void;
|
|
2716
|
+
const firstTurn = new Promise<void>((resolve) => {
|
|
2717
|
+
releaseFirst = resolve;
|
|
2718
|
+
});
|
|
2719
|
+
await shellClientPlugin({
|
|
2720
|
+
transport: {
|
|
2721
|
+
turn: async (_botId, text, _signal, commandId, _skills, supersedes) => {
|
|
2722
|
+
sent.push({ text, ...(supersedes ? { supersedes } : {}) });
|
|
2723
|
+
if (sent.length === 1) await firstTurn;
|
|
2724
|
+
return { runId: commandId, text: `answer to ${text}`, events: [] };
|
|
2725
|
+
},
|
|
2726
|
+
readConfiguration: () =>
|
|
2727
|
+
Promise.resolve(initializeBotSettingsV1("primary")),
|
|
2728
|
+
listRuns: () => Promise.resolve([]),
|
|
2729
|
+
listNotifications: () => Promise.resolve([]),
|
|
2730
|
+
},
|
|
2731
|
+
slot: () => () => {},
|
|
2732
|
+
inject: () => {
|
|
2733
|
+
throw new Error("unexpected client provider injection");
|
|
2734
|
+
},
|
|
2735
|
+
provide: (_key, value) => {
|
|
2736
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2737
|
+
return () => {};
|
|
2738
|
+
},
|
|
2739
|
+
});
|
|
2740
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2741
|
+
provided.value.activeBotId = "primary";
|
|
2742
|
+
provided.value.composerContext = "primary";
|
|
2743
|
+
|
|
2744
|
+
const first = provided.value.sendPrompt("first");
|
|
2745
|
+
await Promise.resolve();
|
|
2746
|
+
const runningRunId = provided.value.activeRunId;
|
|
2747
|
+
expect(runningRunId).toBeString();
|
|
2748
|
+
|
|
2749
|
+
// The composer is open while a Turn runs, and what it sends replaces it.
|
|
2750
|
+
const second = provided.value.sendPrompt("second");
|
|
2751
|
+
releaseFirst();
|
|
2752
|
+
expect(await first).toMatchObject({ accepted: true });
|
|
2753
|
+
expect(await second).toMatchObject({ accepted: true });
|
|
2754
|
+
|
|
2755
|
+
expect(sent.map((entry) => entry.text).sort()).toEqual(["first", "second"]);
|
|
2756
|
+
// Every send carries the intent. The first had observed no run, so it
|
|
2757
|
+
// carries no provenance — and that is exactly the send that used to be
|
|
2758
|
+
// refused when a person typed faster than the client could observe the
|
|
2759
|
+
// Turn it had just started.
|
|
2760
|
+
expect(sent.find((entry) => entry.text === "first")?.supersedes).toEqual(
|
|
2761
|
+
{},
|
|
2762
|
+
);
|
|
2763
|
+
expect(sent.find((entry) => entry.text === "second")?.supersedes).toEqual({
|
|
2764
|
+
runId: runningRunId!,
|
|
2765
|
+
});
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
test("greys the queued Turn, and un-greys it when it starts", () => {
|
|
2769
|
+
const state: Pick<
|
|
2770
|
+
FrockBotWebData,
|
|
2771
|
+
"messages" | "activeRunId" | "runningRunId" | "activeRun"
|
|
2772
|
+
> = { messages: [] };
|
|
2773
|
+
|
|
2774
|
+
projectDurableRuns(
|
|
2775
|
+
state,
|
|
2776
|
+
[],
|
|
2777
|
+
[
|
|
2778
|
+
{ runId: "run-1", input: "first", events: [], status: "running" },
|
|
2779
|
+
{
|
|
2780
|
+
runId: "run-2",
|
|
2781
|
+
input: "second",
|
|
2782
|
+
events: [],
|
|
2783
|
+
status: "running",
|
|
2784
|
+
queued: true,
|
|
2785
|
+
},
|
|
2786
|
+
],
|
|
2787
|
+
);
|
|
2788
|
+
|
|
2789
|
+
// Ordinary messages; the waiting one is simply held back.
|
|
2790
|
+
expect(state.messages.map((message) => message.pending)).toEqual([
|
|
2791
|
+
undefined,
|
|
2792
|
+
undefined,
|
|
2793
|
+
true,
|
|
2794
|
+
true,
|
|
2795
|
+
]);
|
|
2796
|
+
// A new message supersedes the newest unsettled Turn; Stop cancels the one
|
|
2797
|
+
// that is actually executing.
|
|
2798
|
+
expect(state.activeRunId).toBe("run-2");
|
|
2799
|
+
expect(state.runningRunId).toBe("run-1");
|
|
2800
|
+
|
|
2801
|
+
projectDurableRuns(
|
|
2802
|
+
state,
|
|
2803
|
+
[],
|
|
2804
|
+
[
|
|
2805
|
+
{
|
|
2806
|
+
runId: "run-1",
|
|
2807
|
+
input: "first",
|
|
2808
|
+
events: [],
|
|
2809
|
+
status: "superseded",
|
|
2810
|
+
failure: "Interrupted by your next message.",
|
|
2811
|
+
},
|
|
2812
|
+
{ runId: "run-2", input: "second", events: [], status: "running" },
|
|
2813
|
+
],
|
|
2814
|
+
);
|
|
2815
|
+
|
|
2816
|
+
expect(state.messages.every((message) => !message.pending)).toBe(true);
|
|
2817
|
+
expect(state.activeRunId).toBe("run-2");
|
|
2818
|
+
expect(state.runningRunId).toBe("run-2");
|
|
2819
|
+
// The superseded Turn keeps the quiet treatment a stopped one gets.
|
|
2820
|
+
expect(state.messages[1]).toMatchObject({
|
|
2821
|
+
status: "aborted",
|
|
2822
|
+
text: "Interrupted by your next message.",
|
|
2823
|
+
});
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
test("a reload reconstructs the greyed state from durable runs alone", () => {
|
|
2827
|
+
const reloaded: Pick<
|
|
2828
|
+
FrockBotWebData,
|
|
2829
|
+
"messages" | "activeRunId" | "runningRunId" | "activeRun"
|
|
2830
|
+
> = { messages: [] };
|
|
2831
|
+
|
|
2832
|
+
projectDurableRuns(
|
|
2833
|
+
reloaded,
|
|
2834
|
+
[],
|
|
2835
|
+
[
|
|
2836
|
+
{
|
|
2837
|
+
runId: "run-1",
|
|
2838
|
+
input: "first",
|
|
2839
|
+
events: [],
|
|
2840
|
+
status: "superseded",
|
|
2841
|
+
failure: "Interrupted by your next message.",
|
|
2842
|
+
},
|
|
2843
|
+
{
|
|
2844
|
+
runId: "run-2",
|
|
2845
|
+
input: "second",
|
|
2846
|
+
events: [],
|
|
2847
|
+
status: "running",
|
|
2848
|
+
queued: true,
|
|
2849
|
+
},
|
|
2850
|
+
],
|
|
2851
|
+
);
|
|
2852
|
+
|
|
2853
|
+
expect(reloaded.messages[2]).toMatchObject({
|
|
2854
|
+
role: "user",
|
|
2855
|
+
text: "second",
|
|
2856
|
+
pending: true,
|
|
2857
|
+
});
|
|
2858
|
+
expect(reloaded.runningRunId).toBeUndefined();
|
|
2859
|
+
expect(reloaded.activeRunId).toBe("run-2");
|
|
2860
|
+
});
|
|
2861
|
+
});
|
package/src/client/index.ts
CHANGED
|
@@ -98,13 +98,49 @@ import "@frockbot/client-core/fonts.css";
|
|
|
98
98
|
import "./styles.css";
|
|
99
99
|
import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
|
|
100
100
|
|
|
101
|
+
function presentedToolCall(call: NonNullable<ClientTurnEvent["call"]>): {
|
|
102
|
+
name: string;
|
|
103
|
+
input?: unknown;
|
|
104
|
+
} {
|
|
105
|
+
if (
|
|
106
|
+
call.name === "call_dynamic_tool" &&
|
|
107
|
+
typeof call.input === "object" &&
|
|
108
|
+
call.input !== null &&
|
|
109
|
+
!Array.isArray(call.input)
|
|
110
|
+
) {
|
|
111
|
+
const input = call.input as Record<string, unknown>;
|
|
112
|
+
if (
|
|
113
|
+
typeof input.namespace === "string" &&
|
|
114
|
+
typeof input.toolName === "string"
|
|
115
|
+
) {
|
|
116
|
+
let innerArguments: unknown = {};
|
|
117
|
+
if (typeof input.argumentsJson === "string") {
|
|
118
|
+
try {
|
|
119
|
+
innerArguments = JSON.parse(input.argumentsJson) as unknown;
|
|
120
|
+
} catch {
|
|
121
|
+
innerArguments = {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name: `${input.namespace}/${input.toolName}`,
|
|
126
|
+
input: innerArguments,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
name: call.name,
|
|
132
|
+
...(call.input === undefined ? {} : { input: call.input }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
101
136
|
function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
|
|
102
137
|
const tools = new Map<string, WebToolActivity>();
|
|
103
138
|
for (const event of events) {
|
|
104
139
|
if (event.type === "tool/call" && event.call) {
|
|
140
|
+
const presented = presentedToolCall(event.call);
|
|
105
141
|
tools.set(event.call.id, {
|
|
106
142
|
id: event.call.id,
|
|
107
|
-
|
|
143
|
+
...presented,
|
|
108
144
|
status: "running",
|
|
109
145
|
});
|
|
110
146
|
}
|
|
@@ -179,7 +215,7 @@ function tasksFrom(events: ClientTurnEvent[]): WebTaskChip[] {
|
|
|
179
215
|
|
|
180
216
|
type DurableRunProjectionState = Pick<
|
|
181
217
|
FrockBotWebData,
|
|
182
|
-
"messages" | "activeRunId" | "activeRun" | "error"
|
|
218
|
+
"messages" | "activeRunId" | "runningRunId" | "activeRun" | "error"
|
|
183
219
|
>;
|
|
184
220
|
|
|
185
221
|
/**
|
|
@@ -218,7 +254,8 @@ function isTerminalRun(run: ClientRun): boolean {
|
|
|
218
254
|
return (
|
|
219
255
|
run.status === "completed" ||
|
|
220
256
|
run.status === "failed" ||
|
|
221
|
-
run.status === "cancelled"
|
|
257
|
+
run.status === "cancelled" ||
|
|
258
|
+
run.status === "superseded"
|
|
222
259
|
);
|
|
223
260
|
}
|
|
224
261
|
|
|
@@ -235,6 +272,23 @@ function assistantMessage(
|
|
|
235
272
|
role: "assistant",
|
|
236
273
|
text: run.responseText ?? "",
|
|
237
274
|
status: "streaming",
|
|
275
|
+
// A Turn that has not started shows nothing of its own: the greyed user
|
|
276
|
+
// message is the whole of what the thread says about it.
|
|
277
|
+
...(run.queued ? { pending: true } : {}),
|
|
278
|
+
tools: toolsFrom(run.events),
|
|
279
|
+
sends: sendsFrom(run.events),
|
|
280
|
+
tasks: tasksFrom(run.events),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (run.status === "superseded") {
|
|
284
|
+
// The same quiet treatment a stopped Turn gets. It keeps everything it
|
|
285
|
+
// already sent; the line only says why it ends where it does.
|
|
286
|
+
return {
|
|
287
|
+
id: `${run.runId}:assistant`,
|
|
288
|
+
runId: run.runId,
|
|
289
|
+
role: "assistant",
|
|
290
|
+
text: run.failure ?? "Interrupted by your next message.",
|
|
291
|
+
status: "aborted",
|
|
238
292
|
tools: toolsFrom(run.events),
|
|
239
293
|
sends: sendsFrom(run.events),
|
|
240
294
|
tasks: tasksFrom(run.events),
|
|
@@ -321,6 +375,9 @@ export function projectDurableRuns(
|
|
|
321
375
|
// Busy state and the banner are separate: a running Turn keeps the composer
|
|
322
376
|
// busy without producing a banner of its own.
|
|
323
377
|
let busyRunId: string | undefined;
|
|
378
|
+
// The Turn Stop targets: the one that is executing, never the one waiting
|
|
379
|
+
// behind it.
|
|
380
|
+
let runningRunId: string | undefined;
|
|
324
381
|
for (const run of runs) {
|
|
325
382
|
const notification = notifications.find(
|
|
326
383
|
(candidate) => candidate.runId === run.runId,
|
|
@@ -340,6 +397,9 @@ export function projectDurableRuns(
|
|
|
340
397
|
? { at: existingUser.at }
|
|
341
398
|
: {}),
|
|
342
399
|
status: "completed",
|
|
400
|
+
// Greyed while its Turn waits, ordinary the moment it is running. The
|
|
401
|
+
// flag comes from durable run state, so a reload draws the same thing.
|
|
402
|
+
...(run.queued ? { pending: true } : {}),
|
|
343
403
|
tools: [],
|
|
344
404
|
sends: [],
|
|
345
405
|
};
|
|
@@ -360,6 +420,7 @@ export function projectDurableRuns(
|
|
|
360
420
|
activeRun = activeRunView(run) ?? activeRun;
|
|
361
421
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
362
422
|
busyRunId = run.runId;
|
|
423
|
+
if (!run.queued) runningRunId = run.runId;
|
|
363
424
|
}
|
|
364
425
|
if (notification && isTerminalRun(run)) {
|
|
365
426
|
projected.add(notification.notificationId);
|
|
@@ -377,6 +438,10 @@ export function projectDurableRuns(
|
|
|
377
438
|
else if (state.activeRunId && terminalRunIds.has(state.activeRunId)) {
|
|
378
439
|
state.activeRunId = undefined;
|
|
379
440
|
}
|
|
441
|
+
if (runningRunId) state.runningRunId = runningRunId;
|
|
442
|
+
else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
|
|
443
|
+
state.runningRunId = undefined;
|
|
444
|
+
}
|
|
380
445
|
if (activeRun) state.activeRun = activeRun;
|
|
381
446
|
else if (
|
|
382
447
|
state.activeRun &&
|
|
@@ -1156,6 +1221,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1156
1221
|
web.value.messages = [];
|
|
1157
1222
|
web.value.activeRun = undefined;
|
|
1158
1223
|
web.value.activeRunId = undefined;
|
|
1224
|
+
web.value.runningRunId = undefined;
|
|
1159
1225
|
web.value.skillCatalog = [];
|
|
1160
1226
|
web.value.approvals = [];
|
|
1161
1227
|
web.value.tasks = [];
|
|
@@ -2211,12 +2277,20 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2211
2277
|
text: string,
|
|
2212
2278
|
skills?: readonly SkillRefV1[],
|
|
2213
2279
|
): Promise<SendPromptResult> {
|
|
2214
|
-
if (web.value.activeRunId) return { accepted: false, error: "busy" };
|
|
2215
2280
|
const botId = web.value.activeBotId;
|
|
2216
2281
|
if (!botId) return { accepted: false, error: "no-bot" };
|
|
2217
2282
|
const generation = selectionGeneration;
|
|
2218
2283
|
const pendingRunId = crypto.randomUUID();
|
|
2219
2284
|
const optimisticAt = new Date().toISOString();
|
|
2285
|
+
// Every send carries the intent, because "do this instead" is what a
|
|
2286
|
+
// person means by pressing send and it does not depend on what this
|
|
2287
|
+
// client had managed to observe first. Whether a run was showing as
|
|
2288
|
+
// active is a race — the composer unlocks the instant a Turn settles,
|
|
2289
|
+
// and a fast typist beats the next poll — so gating the intent on
|
|
2290
|
+
// `activeRunId` made the Bot refuse a message the User had every right
|
|
2291
|
+
// to send. The observed run rides along as provenance when there is one.
|
|
2292
|
+
const observed = web.value.activeRunId;
|
|
2293
|
+
const supersedes = observed ? { runId: observed } : {};
|
|
2220
2294
|
web.value.activeRunId = pendingRunId;
|
|
2221
2295
|
web.value.error = undefined;
|
|
2222
2296
|
web.value.messages.push(
|
|
@@ -2227,6 +2301,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2227
2301
|
text,
|
|
2228
2302
|
at: optimisticAt,
|
|
2229
2303
|
status: "completed",
|
|
2304
|
+
// Greyed until its own Turn is admitted and running. Optimistic
|
|
2305
|
+
// only: the durable projection replaces it by run id.
|
|
2306
|
+
...(observed ? { pending: true } : {}),
|
|
2230
2307
|
tools: [],
|
|
2231
2308
|
sends: [],
|
|
2232
2309
|
},
|
|
@@ -2237,6 +2314,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2237
2314
|
text: "",
|
|
2238
2315
|
at: optimisticAt,
|
|
2239
2316
|
status: "streaming",
|
|
2317
|
+
...(observed ? { pending: true } : {}),
|
|
2240
2318
|
tools: [],
|
|
2241
2319
|
sends: [],
|
|
2242
2320
|
},
|
|
@@ -2257,6 +2335,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2257
2335
|
requestController.signal,
|
|
2258
2336
|
pendingRunId,
|
|
2259
2337
|
skills,
|
|
2338
|
+
supersedes,
|
|
2260
2339
|
);
|
|
2261
2340
|
if (
|
|
2262
2341
|
generation !== selectionGeneration ||
|
|
@@ -2385,7 +2464,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2385
2464
|
},
|
|
2386
2465
|
async stopRun(): Promise<void> {
|
|
2387
2466
|
const botId = web.value.activeBotId;
|
|
2388
|
-
|
|
2467
|
+
// The Turn that is executing, never the message waiting behind it: Stop
|
|
2468
|
+
// cancels what the Bot is doing and does not discard what the User just
|
|
2469
|
+
// sent.
|
|
2470
|
+
const runId =
|
|
2471
|
+
web.value.activeRun?.runId ??
|
|
2472
|
+
web.value.runningRunId ??
|
|
2473
|
+
web.value.activeRunId;
|
|
2389
2474
|
if (!botId || !runId) return;
|
|
2390
2475
|
if (!ctx.transport.stopRun) {
|
|
2391
2476
|
web.value.settingsError = "Stop is unavailable";
|
package/src/client/styles.css
CHANGED
|
@@ -263,6 +263,17 @@
|
|
|
263
263
|
margin-top: 18px;
|
|
264
264
|
}
|
|
265
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
|
+
|
|
266
277
|
/* A Session announcement: the conversation narrating itself, not a party. */
|
|
267
278
|
.message-system {
|
|
268
279
|
align-items: center;
|
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",
|