@frockbot/plugin-shell 0.3.12 → 0.3.14
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 +34 -32
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +20 -20
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +299 -68
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +103 -16
- package/src/client/index.ts +187 -79
- package/src/client/styles.css +56 -61
- package/src/client/voice-dictation.test.ts +105 -0
- package/src/client/voice-dictation.ts +137 -0
- package/src/client/voice-microphone.ts +125 -0
- package/src/client/voice-worklet.ts +75 -0
- package/src/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +13 -7
- package/src/run-protocol.ts +17 -7
- package/src/shared.ts +17 -0
package/src/client/index.test.ts
CHANGED
|
@@ -1526,16 +1526,95 @@ describe("active durable Turn projection", () => {
|
|
|
1526
1526
|
],
|
|
1527
1527
|
);
|
|
1528
1528
|
|
|
1529
|
-
|
|
1529
|
+
// One message per send, in the order they were sent, and nothing merged:
|
|
1530
|
+
// the last line is the Turn's own, and it carries no send at all.
|
|
1531
|
+
expect(
|
|
1532
|
+
state.messages.slice(1).map((message) => [message.id, message.sends]),
|
|
1533
|
+
).toEqual([
|
|
1534
|
+
[
|
|
1535
|
+
"run-send:send:0",
|
|
1536
|
+
[{ kind: "payload", payload: { type: "text", text: "On it." } }],
|
|
1537
|
+
],
|
|
1538
|
+
[
|
|
1539
|
+
"run-send:send:1",
|
|
1540
|
+
[
|
|
1541
|
+
{
|
|
1542
|
+
kind: "payload",
|
|
1543
|
+
payload: {
|
|
1544
|
+
type: "widget",
|
|
1545
|
+
widget: { prompt: "Which day?", options: ["Tue"] },
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
],
|
|
1549
|
+
],
|
|
1550
|
+
["run-send:send:2", [{ kind: "unsupported" }]],
|
|
1551
|
+
["run-send:assistant", []],
|
|
1552
|
+
]);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
test("a second send is a new bubble, and never rewrites the first", () => {
|
|
1556
|
+
const state: Pick<
|
|
1557
|
+
FrockBotWebData,
|
|
1558
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1559
|
+
> = { messages: [] };
|
|
1560
|
+
const run = (
|
|
1561
|
+
events: ClientRun["events"],
|
|
1562
|
+
status: ClientRun["status"] = "running",
|
|
1563
|
+
): ClientRun => ({
|
|
1564
|
+
runId: "run-stack",
|
|
1565
|
+
input: "Book it",
|
|
1566
|
+
admittedAt: "2026-09-04T00:00:00.000Z",
|
|
1567
|
+
status,
|
|
1568
|
+
events,
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// The acknowledgement arrives first, on its own.
|
|
1572
|
+
projectDurableRuns(state, [], [
|
|
1573
|
+
run([
|
|
1574
|
+
{ type: "send/to-user", payload: { type: "text", text: "On it." } },
|
|
1575
|
+
]),
|
|
1576
|
+
] as ClientRun[]);
|
|
1577
|
+
const acknowledgement = state.messages[1];
|
|
1578
|
+
expect(acknowledgement?.sends).toEqual([
|
|
1530
1579
|
{ kind: "payload", payload: { type: "text", text: "On it." } },
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1580
|
+
]);
|
|
1581
|
+
|
|
1582
|
+
// The result follows in the same Turn. It appends: the first bubble is
|
|
1583
|
+
// still the first bubble, still saying what it said.
|
|
1584
|
+
projectDurableRuns(state, [], [
|
|
1585
|
+
run(
|
|
1586
|
+
[
|
|
1587
|
+
{
|
|
1588
|
+
type: "send/to-user",
|
|
1589
|
+
payload: { type: "text", text: "On it." },
|
|
1590
|
+
},
|
|
1591
|
+
{ type: "send/to-user", payload: { type: "text", text: "Booked." } },
|
|
1592
|
+
],
|
|
1593
|
+
"completed",
|
|
1594
|
+
),
|
|
1595
|
+
] as ClientRun[]);
|
|
1596
|
+
|
|
1597
|
+
expect(
|
|
1598
|
+
state.messages
|
|
1599
|
+
.filter((message) => message.sends.length > 0)
|
|
1600
|
+
.map((message) => [message.id, message.sends]),
|
|
1601
|
+
).toEqual([
|
|
1602
|
+
[
|
|
1603
|
+
"run-stack:send:0",
|
|
1604
|
+
[{ kind: "payload", payload: { type: "text", text: "On it." } }],
|
|
1605
|
+
],
|
|
1606
|
+
[
|
|
1607
|
+
"run-stack:send:1",
|
|
1608
|
+
[{ kind: "payload", payload: { type: "text", text: "Booked." } }],
|
|
1609
|
+
],
|
|
1610
|
+
]);
|
|
1611
|
+
// The Turn's lines stay together and in order behind the prompt that
|
|
1612
|
+
// started it, so a reload draws the thread that was watched being written.
|
|
1613
|
+
expect(state.messages.map((message) => message.id)).toEqual([
|
|
1614
|
+
"run-stack:user",
|
|
1615
|
+
"run-stack:send:0",
|
|
1616
|
+
"run-stack:send:1",
|
|
1617
|
+
"run-stack:assistant",
|
|
1539
1618
|
]);
|
|
1540
1619
|
});
|
|
1541
1620
|
|
|
@@ -1561,8 +1640,8 @@ describe("active durable Turn projection", () => {
|
|
|
1561
1640
|
],
|
|
1562
1641
|
);
|
|
1563
1642
|
|
|
1564
|
-
expect(state.messages[1]?.text).toBe("");
|
|
1565
1643
|
expect(state.messages[1]?.sends).toHaveLength(1);
|
|
1644
|
+
expect(state.messages[2]?.text).toBe("");
|
|
1566
1645
|
});
|
|
1567
1646
|
|
|
1568
1647
|
test("a Turn that sent nothing still draws the model's text", () => {
|
|
@@ -1712,8 +1791,8 @@ describe("active durable Turn projection", () => {
|
|
|
1712
1791
|
},
|
|
1713
1792
|
],
|
|
1714
1793
|
);
|
|
1715
|
-
expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
|
|
1716
1794
|
expect(state.messages[1]?.sends).toHaveLength(1);
|
|
1795
|
+
expect(state.messages[2]).toMatchObject({ text: "", status: "streaming" });
|
|
1717
1796
|
});
|
|
1718
1797
|
|
|
1719
1798
|
test("projects reconciliation-required recovery state", () => {
|
|
@@ -3215,11 +3294,14 @@ describe("a message sent while a Turn is running", () => {
|
|
|
3215
3294
|
expect(state.messages.every((message) => !message.pending)).toBe(true);
|
|
3216
3295
|
expect(state.activeRunId).toBe("run-2");
|
|
3217
3296
|
expect(state.runningRunId).toBe("run-2");
|
|
3218
|
-
// The superseded Turn
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3297
|
+
// The superseded Turn is left unlabelled: the message that replaced it is
|
|
3298
|
+
// right underneath, and it says why the Turn ends there better than a
|
|
3299
|
+
// notice of ours would.
|
|
3300
|
+
expect(state.messages[1]).toMatchObject({ status: "aborted" });
|
|
3301
|
+
expect(state.messages[1]?.notice).toBeUndefined();
|
|
3302
|
+
expect(state.messages.some((message) => message.notice !== undefined)).toBe(
|
|
3303
|
+
false,
|
|
3304
|
+
);
|
|
3223
3305
|
});
|
|
3224
3306
|
|
|
3225
3307
|
test("a reload reconstructs the greyed state from durable runs alone", () => {
|
|
@@ -3254,6 +3336,11 @@ describe("a message sent while a Turn is running", () => {
|
|
|
3254
3336
|
text: "second",
|
|
3255
3337
|
pending: true,
|
|
3256
3338
|
});
|
|
3339
|
+
// The durable record still says why the Turn ended; the transcript does
|
|
3340
|
+
// not repeat it at the reader.
|
|
3341
|
+
expect(
|
|
3342
|
+
reloaded.messages.some((message) => message.notice !== undefined),
|
|
3343
|
+
).toBe(false);
|
|
3257
3344
|
expect(reloaded.runningRunId).toBeUndefined();
|
|
3258
3345
|
expect(reloaded.activeRunId).toBe("run-2");
|
|
3259
3346
|
});
|
package/src/client/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "@frockbot/client-core";
|
|
16
16
|
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
17
17
|
import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
|
|
18
|
+
import { voiceCaptureSupportedV1 } from "./voice-microphone.js";
|
|
18
19
|
import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
|
|
19
20
|
// Connection mutations use the provider-neutral hosted command contract.
|
|
20
21
|
import type {
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
|
|
59
60
|
import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
60
61
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
62
|
+
import { knownFailureCopyV1 } from "../run-failure-copy.js";
|
|
61
63
|
import {
|
|
62
64
|
ClientTurnRefusedErrorV1,
|
|
63
65
|
type ClientTurnRefusalReasonV1,
|
|
@@ -293,13 +295,28 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
|
293
295
|
* The Bot's voice is its sends. When a Turn delivered anything to the User the
|
|
294
296
|
* model's own assistant text is scratch space and the thread does not draw it
|
|
295
297
|
* (issue 153): drawing both is how a one-word reply arrived twice, once as the
|
|
296
|
-
* model's text and once as the bubble that was actually delivered
|
|
298
|
+
* model's text and once as the bubble that was actually delivered — the Turn's
|
|
299
|
+
* derived text is the last text send when the model wrote no message of its
|
|
300
|
+
* own (`backend-runner.ts`'s `lastSentTextV1`), so it is literally that copy.
|
|
297
301
|
*
|
|
298
302
|
* A running Turn has no `responseText` yet — that is written only at
|
|
299
303
|
* settlement — so it draws the words it has written so far. They occupy the
|
|
300
|
-
* same bubble the settled answer will, and
|
|
301
|
-
*
|
|
302
|
-
*
|
|
304
|
+
* same bubble the settled answer will, and that bubble is the Turn's own line,
|
|
305
|
+
* which follows the sends rather than replacing any of them.
|
|
306
|
+
*
|
|
307
|
+
* The gate is absolute, and it has to be. Relaxing it to "suppress only text
|
|
308
|
+
* that duplicates a send" looked safer and was not: the model's *last* step
|
|
309
|
+
* routinely writes something of its own after the step that spoke — the e2e
|
|
310
|
+
* that pins this sends "pong" through the tool and then answers again in text —
|
|
311
|
+
* and comparing the two drew both. Two bubbles for one reply is the exact
|
|
312
|
+
* regression issue 153 named.
|
|
313
|
+
*
|
|
314
|
+
* So a Turn that sent anything is drawn entirely from its sends, and text the
|
|
315
|
+
* model wrote beside them is scratch space. That is what makes the promotion in
|
|
316
|
+
* `promoteAssistantTextToSendV1` the right shape: an acknowledgement reaches
|
|
317
|
+
* the person by *becoming* a send — and under the per-send projection it is
|
|
318
|
+
* then its own bubble, in the order it was journaled — rather than being drawn
|
|
319
|
+
* as text next to one.
|
|
303
320
|
*/
|
|
304
321
|
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
305
322
|
if (sendsFrom(run.events).length > 0) return "";
|
|
@@ -315,6 +332,33 @@ function isTerminalRun(run: ClientRun): boolean {
|
|
|
315
332
|
);
|
|
316
333
|
}
|
|
317
334
|
|
|
335
|
+
/**
|
|
336
|
+
* One message per `send_to_user`, in the order the Bot sent them.
|
|
337
|
+
*
|
|
338
|
+
* A Turn is not one bubble. The Bot acknowledges, works, and reports back,
|
|
339
|
+
* and each of those is a message in the conversation exactly as it would be
|
|
340
|
+
* from a person (issue 153). The order is the durable order of the run's
|
|
341
|
+
* events, so the thread a reload draws is the thread that was watched being
|
|
342
|
+
* written, and a bubble is never edited once it is in the transcript: a later
|
|
343
|
+
* send appends, it does not replace.
|
|
344
|
+
*/
|
|
345
|
+
function sendMessages(run: ClientRun): WebChatMessage[] {
|
|
346
|
+
return sendsFrom(run.events).map((send, index) => ({
|
|
347
|
+
id: `${run.runId}:send:${index}`,
|
|
348
|
+
runId: run.runId,
|
|
349
|
+
role: "assistant" as const,
|
|
350
|
+
text: "",
|
|
351
|
+
status: "completed" as const,
|
|
352
|
+
tools: [],
|
|
353
|
+
sends: [send],
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The Turn's own line: the model's words, why the Turn ended where it did,
|
|
359
|
+
* the tools it ran and the subagents it dispatched. It closes the run, under
|
|
360
|
+
* whatever the Turn had already sent.
|
|
361
|
+
*/
|
|
318
362
|
function assistantMessage(
|
|
319
363
|
run: ClientRun,
|
|
320
364
|
notification: ClientNotificationIntent | undefined,
|
|
@@ -332,22 +376,23 @@ function assistantMessage(
|
|
|
332
376
|
// message is the whole of what the thread says about it.
|
|
333
377
|
...(run.queued ? { pending: true } : {}),
|
|
334
378
|
tools: toolsFrom(run.events),
|
|
335
|
-
sends:
|
|
379
|
+
sends: [],
|
|
336
380
|
tasks: tasksFrom(run.events),
|
|
337
381
|
};
|
|
338
382
|
}
|
|
339
383
|
if (run.status === "superseded") {
|
|
340
|
-
//
|
|
341
|
-
//
|
|
384
|
+
// Quieter than a stopped Turn: it keeps everything it already sent and
|
|
385
|
+
// carries no notice at all. The message that superseded it is sitting
|
|
386
|
+
// right underneath, in the person's own words, and it explains the ending
|
|
387
|
+
// better than a line of ours would (ADR 0024).
|
|
342
388
|
return {
|
|
343
389
|
id: `${run.runId}:assistant`,
|
|
344
390
|
runId: run.runId,
|
|
345
391
|
role: "assistant",
|
|
346
392
|
text: visibleAssistantText(run),
|
|
347
|
-
notice: "Interrupted by your next message.",
|
|
348
393
|
status: "aborted",
|
|
349
394
|
tools: toolsFrom(run.events),
|
|
350
|
-
sends:
|
|
395
|
+
sends: [],
|
|
351
396
|
tasks: tasksFrom(run.events),
|
|
352
397
|
};
|
|
353
398
|
}
|
|
@@ -367,7 +412,7 @@ function assistantMessage(
|
|
|
367
412
|
notice: "This reply stopped partway. Try again to continue it.",
|
|
368
413
|
status: "reconciliation-required",
|
|
369
414
|
tools: toolsFrom(run.events),
|
|
370
|
-
sends:
|
|
415
|
+
sends: [],
|
|
371
416
|
tasks: tasksFrom(run.events),
|
|
372
417
|
};
|
|
373
418
|
}
|
|
@@ -380,7 +425,7 @@ function assistantMessage(
|
|
|
380
425
|
notice: "You stopped this.",
|
|
381
426
|
status: "aborted",
|
|
382
427
|
tools: toolsFrom(run.events),
|
|
383
|
-
sends:
|
|
428
|
+
sends: [],
|
|
384
429
|
tasks: tasksFrom(run.events),
|
|
385
430
|
};
|
|
386
431
|
}
|
|
@@ -394,14 +439,18 @@ function assistantMessage(
|
|
|
394
439
|
runId: run.runId,
|
|
395
440
|
role: "assistant",
|
|
396
441
|
text: run.responseText,
|
|
397
|
-
// The
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
442
|
+
// The durable failure text is a provider's, not the product's — `Bot
|
|
443
|
+
// turn ended with outcome model-error`, a status code, once a run UUID —
|
|
444
|
+
// and under a bubble it reads as part of what the Bot was saying. By the
|
|
445
|
+
// time it reaches here it is already the sentence for a person: the
|
|
446
|
+
// projection maps it through `runFailureCopyV1` before it crosses the
|
|
447
|
+
// wire, so this keeps whatever that chose — the model-deadline copy says
|
|
448
|
+
// something the outcome alone cannot — and falls back to the same line a
|
|
449
|
+
// reply-less failure gets.
|
|
450
|
+
notice: knownFailureCopyV1(run.failure),
|
|
402
451
|
status: "error",
|
|
403
452
|
tools: toolsFrom(run.events),
|
|
404
|
-
sends:
|
|
453
|
+
sends: [],
|
|
405
454
|
tasks: tasksFrom(run.events),
|
|
406
455
|
};
|
|
407
456
|
}
|
|
@@ -416,11 +465,11 @@ function assistantMessage(
|
|
|
416
465
|
// Why the Turn ends there, under whatever it had already said — never as
|
|
417
466
|
// the bubble's own text, which reads as the Bot saying it.
|
|
418
467
|
...(run.status === "failed"
|
|
419
|
-
? { notice:
|
|
468
|
+
? { notice: knownFailureCopyV1(run.failure) }
|
|
420
469
|
: {}),
|
|
421
470
|
status: run.status === "failed" ? "error" : "completed",
|
|
422
471
|
tools: toolsFrom(run.events),
|
|
423
|
-
sends:
|
|
472
|
+
sends: [],
|
|
424
473
|
tasks: tasksFrom(run.events),
|
|
425
474
|
};
|
|
426
475
|
}
|
|
@@ -521,16 +570,12 @@ export function projectDurableRuns(
|
|
|
521
570
|
if (userIndex >= 0) state.messages[userIndex] = user;
|
|
522
571
|
else state.messages.push(user);
|
|
523
572
|
|
|
524
|
-
|
|
525
|
-
|
|
573
|
+
replaceTurnMessages(
|
|
574
|
+
state.messages,
|
|
575
|
+
run.runId,
|
|
576
|
+
[...sendMessages(run), assistantMessage(run, notification)],
|
|
577
|
+
run.admittedAt,
|
|
526
578
|
);
|
|
527
|
-
const assistant = assistantMessage(run, notification);
|
|
528
|
-
const assistantAt =
|
|
529
|
-
run.admittedAt ??
|
|
530
|
-
(assistantIndex >= 0 ? state.messages[assistantIndex]?.at : undefined);
|
|
531
|
-
if (assistantAt) assistant.at = assistantAt;
|
|
532
|
-
if (assistantIndex >= 0) state.messages[assistantIndex] = assistant;
|
|
533
|
-
else state.messages.push(assistant);
|
|
534
579
|
|
|
535
580
|
activeRun = activeRunView(run) ?? activeRun;
|
|
536
581
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
@@ -1373,6 +1418,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1373
1418
|
modelSource: "none",
|
|
1374
1419
|
settingsAvailable: true,
|
|
1375
1420
|
connectionsAvailable: ctx.transport.connectionsAvailable !== false,
|
|
1421
|
+
voiceAvailable:
|
|
1422
|
+
typeof ctx.transport.openVoiceDictation === "function" &&
|
|
1423
|
+
voiceCaptureSupportedV1(),
|
|
1376
1424
|
activeBotId: undefined,
|
|
1377
1425
|
composerContext: undefined,
|
|
1378
1426
|
transcripts: {
|
|
@@ -2638,19 +2686,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2638
2686
|
message.runId = result.runId;
|
|
2639
2687
|
message.id = `${result.runId}:${message.role}`;
|
|
2640
2688
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2689
|
+
// Projected exactly as the durable read projects it, from the same
|
|
2690
|
+
// events: one bubble per send, in order, and the model's own text
|
|
2691
|
+
// after them (issue 153). The POST's copy of a Turn and the transcript
|
|
2692
|
+
// read must draw the same thread, or the reply rearranges itself on
|
|
2693
|
+
// the next reload.
|
|
2694
|
+
const settled: ClientRun = {
|
|
2643
2695
|
runId: result.runId,
|
|
2644
|
-
|
|
2645
|
-
// The same rule the durable projection follows: a Turn that
|
|
2646
|
-
// delivered something speaks through its sends, not through the
|
|
2647
|
-
// model's own text (issue 153).
|
|
2648
|
-
text: sendsFrom(result.events).length > 0 ? "" : result.text,
|
|
2649
|
-
at: optimisticAt,
|
|
2696
|
+
input: text,
|
|
2650
2697
|
status: "completed",
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
}
|
|
2698
|
+
responseText: result.text,
|
|
2699
|
+
events: result.events,
|
|
2700
|
+
};
|
|
2701
|
+
replaceTurnMessages(
|
|
2702
|
+
web.value.messages,
|
|
2703
|
+
result.runId,
|
|
2704
|
+
[...sendMessages(settled), assistantMessage(settled, undefined)],
|
|
2705
|
+
optimisticAt,
|
|
2706
|
+
);
|
|
2654
2707
|
try {
|
|
2655
2708
|
await deliverNotifications(botId, generation);
|
|
2656
2709
|
} catch (error) {
|
|
@@ -2709,16 +2762,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2709
2762
|
message.runId === pendingRunId && message.role === "user",
|
|
2710
2763
|
)?.at ?? optimisticAt,
|
|
2711
2764
|
);
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2765
|
+
replaceTurnMessages(
|
|
2766
|
+
web.value.messages,
|
|
2767
|
+
pendingRunId,
|
|
2768
|
+
[
|
|
2769
|
+
{
|
|
2770
|
+
id: `${pendingRunId}:assistant`,
|
|
2771
|
+
runId: pendingRunId,
|
|
2772
|
+
role: "assistant",
|
|
2773
|
+
text: "Checking whether your message went through…",
|
|
2774
|
+
at: placeholderAt,
|
|
2775
|
+
status: "interrupted",
|
|
2776
|
+
tools: [],
|
|
2777
|
+
sends: [],
|
|
2778
|
+
},
|
|
2779
|
+
],
|
|
2780
|
+
placeholderAt,
|
|
2781
|
+
);
|
|
2722
2782
|
if (aborted) {
|
|
2723
2783
|
web.value.error = undefined;
|
|
2724
2784
|
} else {
|
|
@@ -2744,17 +2804,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2744
2804
|
// have to improvise — and with the Turn no longer running, so Stop
|
|
2745
2805
|
// stops standing for a Turn nobody is executing.
|
|
2746
2806
|
if (disposition === "unreachable") {
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2807
|
+
replaceTurnMessages(
|
|
2808
|
+
web.value.messages,
|
|
2809
|
+
pendingRunId,
|
|
2810
|
+
[
|
|
2811
|
+
{
|
|
2812
|
+
id: `${pendingRunId}:assistant`,
|
|
2813
|
+
runId: pendingRunId,
|
|
2814
|
+
role: "assistant",
|
|
2815
|
+
text: UNREACHABLE_BOT_MESSAGE_V1,
|
|
2816
|
+
at: placeholderAt,
|
|
2817
|
+
status: "error",
|
|
2818
|
+
retry: "resend",
|
|
2819
|
+
tools: [],
|
|
2820
|
+
sends: [],
|
|
2821
|
+
},
|
|
2822
|
+
],
|
|
2823
|
+
placeholderAt,
|
|
2824
|
+
);
|
|
2758
2825
|
// The bubble is the report, and it is the one carrying the Retry.
|
|
2759
2826
|
// Saying the same sentence again in the banner above it is what the
|
|
2760
2827
|
// thread already looked like when it was broken — the same string
|
|
@@ -2774,19 +2841,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2774
2841
|
// retry, and one system line says so.
|
|
2775
2842
|
const notAdmitted =
|
|
2776
2843
|
"Your message didn't go through. Try sending it again.";
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2844
|
+
replaceTurnMessages(
|
|
2845
|
+
web.value.messages,
|
|
2846
|
+
pendingRunId,
|
|
2847
|
+
[
|
|
2848
|
+
{
|
|
2849
|
+
id: `${pendingRunId}:assistant`,
|
|
2850
|
+
runId: pendingRunId,
|
|
2851
|
+
role: "system",
|
|
2852
|
+
text: notAdmitted,
|
|
2853
|
+
at: placeholderAt,
|
|
2854
|
+
status: "error",
|
|
2855
|
+
// The same affordance an unreachable send gets: the draft is
|
|
2856
|
+
// back in the composer, and this sends it again.
|
|
2857
|
+
retry: "resend",
|
|
2858
|
+
tools: [],
|
|
2859
|
+
sends: [],
|
|
2860
|
+
},
|
|
2861
|
+
],
|
|
2862
|
+
placeholderAt,
|
|
2863
|
+
);
|
|
2790
2864
|
return { accepted: false, error: notAdmitted };
|
|
2791
2865
|
}
|
|
2792
2866
|
return { accepted: true, runId: pendingRunId };
|
|
@@ -2808,6 +2882,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2808
2882
|
}
|
|
2809
2883
|
}
|
|
2810
2884
|
},
|
|
2885
|
+
openVoiceDictation(observer) {
|
|
2886
|
+
return ctx.transport.openVoiceDictation?.(observer);
|
|
2887
|
+
},
|
|
2811
2888
|
async resumeRun(runId: string): Promise<void> {
|
|
2812
2889
|
if (!ctx.transport.reconcileRun) {
|
|
2813
2890
|
web.value.settingsError = "Can't retry this right now.";
|
|
@@ -3081,15 +3158,46 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3081
3158
|
];
|
|
3082
3159
|
};
|
|
3083
3160
|
|
|
3084
|
-
|
|
3161
|
+
/**
|
|
3162
|
+
* Puts a Turn's Bot-side lines in the thread, in order, in one place.
|
|
3163
|
+
*
|
|
3164
|
+
* A Turn is one user message and then however many the Bot sent, so a merge
|
|
3165
|
+
* cannot key one bubble by run id and overwrite it — that is exactly how a
|
|
3166
|
+
* second `send_to_user` used to replace the first. Every Bot-side line the
|
|
3167
|
+
* run already has is lifted out and the new ones go back at the same
|
|
3168
|
+
* position, which keeps the sends in their durable order and keeps the whole
|
|
3169
|
+
* Turn together between the Turn before it and the Turn after it.
|
|
3170
|
+
*
|
|
3171
|
+
* Where a line already carried a timestamp and the caller offers none, the
|
|
3172
|
+
* one it had is kept: the thread sorts by time and a line must not jump.
|
|
3173
|
+
*/
|
|
3174
|
+
function replaceTurnMessages(
|
|
3085
3175
|
messages: WebChatMessage[],
|
|
3086
3176
|
runId: string,
|
|
3087
|
-
|
|
3177
|
+
replacements: WebChatMessage[],
|
|
3178
|
+
at?: string,
|
|
3088
3179
|
): void {
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
)
|
|
3092
|
-
|
|
3180
|
+
let start = -1;
|
|
3181
|
+
let existingAt: string | undefined;
|
|
3182
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
3183
|
+
const message = messages[index];
|
|
3184
|
+
if (!message || message.runId !== runId || message.role !== "assistant")
|
|
3185
|
+
continue;
|
|
3186
|
+
existingAt = message.at ?? existingAt;
|
|
3187
|
+
start = index;
|
|
3188
|
+
messages.splice(index, 1);
|
|
3189
|
+
}
|
|
3190
|
+
if (start < 0) {
|
|
3191
|
+
// No Bot-side line yet: it belongs directly after this Turn's prompt, so
|
|
3192
|
+
// an older Turn's reply can never come between them.
|
|
3193
|
+
const userIndex = messages.findIndex(
|
|
3194
|
+
(message) => message.runId === runId && message.role === "user",
|
|
3195
|
+
);
|
|
3196
|
+
start = userIndex >= 0 ? userIndex + 1 : messages.length;
|
|
3197
|
+
}
|
|
3198
|
+
const stamp = at ?? existingAt;
|
|
3199
|
+
if (stamp) for (const message of replacements) message.at = stamp;
|
|
3200
|
+
messages.splice(start, 0, ...replacements);
|
|
3093
3201
|
}
|
|
3094
3202
|
|
|
3095
3203
|
/** Takes back both optimistic lines of a send the Bot never admitted. */
|