@frockbot/plugin-shell 0.3.12 → 0.3.13
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 +33 -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 +67 -67
- 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 +180 -79
- package/src/client/styles.css +12 -61
- 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/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
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
|
|
59
59
|
import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
60
60
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
61
|
+
import { knownFailureCopyV1 } from "../run-failure-copy.js";
|
|
61
62
|
import {
|
|
62
63
|
ClientTurnRefusedErrorV1,
|
|
63
64
|
type ClientTurnRefusalReasonV1,
|
|
@@ -293,13 +294,28 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
|
293
294
|
* The Bot's voice is its sends. When a Turn delivered anything to the User the
|
|
294
295
|
* model's own assistant text is scratch space and the thread does not draw it
|
|
295
296
|
* (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
|
|
297
|
+
* model's text and once as the bubble that was actually delivered — the Turn's
|
|
298
|
+
* derived text is the last text send when the model wrote no message of its
|
|
299
|
+
* own (`backend-runner.ts`'s `lastSentTextV1`), so it is literally that copy.
|
|
297
300
|
*
|
|
298
301
|
* A running Turn has no `responseText` yet — that is written only at
|
|
299
302
|
* settlement — so it draws the words it has written so far. They occupy the
|
|
300
|
-
* same bubble the settled answer will, and
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
+
* same bubble the settled answer will, and that bubble is the Turn's own line,
|
|
304
|
+
* which follows the sends rather than replacing any of them.
|
|
305
|
+
*
|
|
306
|
+
* The gate is absolute, and it has to be. Relaxing it to "suppress only text
|
|
307
|
+
* that duplicates a send" looked safer and was not: the model's *last* step
|
|
308
|
+
* routinely writes something of its own after the step that spoke — the e2e
|
|
309
|
+
* that pins this sends "pong" through the tool and then answers again in text —
|
|
310
|
+
* and comparing the two drew both. Two bubbles for one reply is the exact
|
|
311
|
+
* regression issue 153 named.
|
|
312
|
+
*
|
|
313
|
+
* So a Turn that sent anything is drawn entirely from its sends, and text the
|
|
314
|
+
* model wrote beside them is scratch space. That is what makes the promotion in
|
|
315
|
+
* `promoteAssistantTextToSendV1` the right shape: an acknowledgement reaches
|
|
316
|
+
* the person by *becoming* a send — and under the per-send projection it is
|
|
317
|
+
* then its own bubble, in the order it was journaled — rather than being drawn
|
|
318
|
+
* as text next to one.
|
|
303
319
|
*/
|
|
304
320
|
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
305
321
|
if (sendsFrom(run.events).length > 0) return "";
|
|
@@ -315,6 +331,33 @@ function isTerminalRun(run: ClientRun): boolean {
|
|
|
315
331
|
);
|
|
316
332
|
}
|
|
317
333
|
|
|
334
|
+
/**
|
|
335
|
+
* One message per `send_to_user`, in the order the Bot sent them.
|
|
336
|
+
*
|
|
337
|
+
* A Turn is not one bubble. The Bot acknowledges, works, and reports back,
|
|
338
|
+
* and each of those is a message in the conversation exactly as it would be
|
|
339
|
+
* from a person (issue 153). The order is the durable order of the run's
|
|
340
|
+
* events, so the thread a reload draws is the thread that was watched being
|
|
341
|
+
* written, and a bubble is never edited once it is in the transcript: a later
|
|
342
|
+
* send appends, it does not replace.
|
|
343
|
+
*/
|
|
344
|
+
function sendMessages(run: ClientRun): WebChatMessage[] {
|
|
345
|
+
return sendsFrom(run.events).map((send, index) => ({
|
|
346
|
+
id: `${run.runId}:send:${index}`,
|
|
347
|
+
runId: run.runId,
|
|
348
|
+
role: "assistant" as const,
|
|
349
|
+
text: "",
|
|
350
|
+
status: "completed" as const,
|
|
351
|
+
tools: [],
|
|
352
|
+
sends: [send],
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The Turn's own line: the model's words, why the Turn ended where it did,
|
|
358
|
+
* the tools it ran and the subagents it dispatched. It closes the run, under
|
|
359
|
+
* whatever the Turn had already sent.
|
|
360
|
+
*/
|
|
318
361
|
function assistantMessage(
|
|
319
362
|
run: ClientRun,
|
|
320
363
|
notification: ClientNotificationIntent | undefined,
|
|
@@ -332,22 +375,23 @@ function assistantMessage(
|
|
|
332
375
|
// message is the whole of what the thread says about it.
|
|
333
376
|
...(run.queued ? { pending: true } : {}),
|
|
334
377
|
tools: toolsFrom(run.events),
|
|
335
|
-
sends:
|
|
378
|
+
sends: [],
|
|
336
379
|
tasks: tasksFrom(run.events),
|
|
337
380
|
};
|
|
338
381
|
}
|
|
339
382
|
if (run.status === "superseded") {
|
|
340
|
-
//
|
|
341
|
-
//
|
|
383
|
+
// Quieter than a stopped Turn: it keeps everything it already sent and
|
|
384
|
+
// carries no notice at all. The message that superseded it is sitting
|
|
385
|
+
// right underneath, in the person's own words, and it explains the ending
|
|
386
|
+
// better than a line of ours would (ADR 0024).
|
|
342
387
|
return {
|
|
343
388
|
id: `${run.runId}:assistant`,
|
|
344
389
|
runId: run.runId,
|
|
345
390
|
role: "assistant",
|
|
346
391
|
text: visibleAssistantText(run),
|
|
347
|
-
notice: "Interrupted by your next message.",
|
|
348
392
|
status: "aborted",
|
|
349
393
|
tools: toolsFrom(run.events),
|
|
350
|
-
sends:
|
|
394
|
+
sends: [],
|
|
351
395
|
tasks: tasksFrom(run.events),
|
|
352
396
|
};
|
|
353
397
|
}
|
|
@@ -367,7 +411,7 @@ function assistantMessage(
|
|
|
367
411
|
notice: "This reply stopped partway. Try again to continue it.",
|
|
368
412
|
status: "reconciliation-required",
|
|
369
413
|
tools: toolsFrom(run.events),
|
|
370
|
-
sends:
|
|
414
|
+
sends: [],
|
|
371
415
|
tasks: tasksFrom(run.events),
|
|
372
416
|
};
|
|
373
417
|
}
|
|
@@ -380,7 +424,7 @@ function assistantMessage(
|
|
|
380
424
|
notice: "You stopped this.",
|
|
381
425
|
status: "aborted",
|
|
382
426
|
tools: toolsFrom(run.events),
|
|
383
|
-
sends:
|
|
427
|
+
sends: [],
|
|
384
428
|
tasks: tasksFrom(run.events),
|
|
385
429
|
};
|
|
386
430
|
}
|
|
@@ -394,14 +438,18 @@ function assistantMessage(
|
|
|
394
438
|
runId: run.runId,
|
|
395
439
|
role: "assistant",
|
|
396
440
|
text: run.responseText,
|
|
397
|
-
// The
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
441
|
+
// The durable failure text is a provider's, not the product's — `Bot
|
|
442
|
+
// turn ended with outcome model-error`, a status code, once a run UUID —
|
|
443
|
+
// and under a bubble it reads as part of what the Bot was saying. By the
|
|
444
|
+
// time it reaches here it is already the sentence for a person: the
|
|
445
|
+
// projection maps it through `runFailureCopyV1` before it crosses the
|
|
446
|
+
// wire, so this keeps whatever that chose — the model-deadline copy says
|
|
447
|
+
// something the outcome alone cannot — and falls back to the same line a
|
|
448
|
+
// reply-less failure gets.
|
|
449
|
+
notice: knownFailureCopyV1(run.failure),
|
|
402
450
|
status: "error",
|
|
403
451
|
tools: toolsFrom(run.events),
|
|
404
|
-
sends:
|
|
452
|
+
sends: [],
|
|
405
453
|
tasks: tasksFrom(run.events),
|
|
406
454
|
};
|
|
407
455
|
}
|
|
@@ -416,11 +464,11 @@ function assistantMessage(
|
|
|
416
464
|
// Why the Turn ends there, under whatever it had already said — never as
|
|
417
465
|
// the bubble's own text, which reads as the Bot saying it.
|
|
418
466
|
...(run.status === "failed"
|
|
419
|
-
? { notice:
|
|
467
|
+
? { notice: knownFailureCopyV1(run.failure) }
|
|
420
468
|
: {}),
|
|
421
469
|
status: run.status === "failed" ? "error" : "completed",
|
|
422
470
|
tools: toolsFrom(run.events),
|
|
423
|
-
sends:
|
|
471
|
+
sends: [],
|
|
424
472
|
tasks: tasksFrom(run.events),
|
|
425
473
|
};
|
|
426
474
|
}
|
|
@@ -521,16 +569,12 @@ export function projectDurableRuns(
|
|
|
521
569
|
if (userIndex >= 0) state.messages[userIndex] = user;
|
|
522
570
|
else state.messages.push(user);
|
|
523
571
|
|
|
524
|
-
|
|
525
|
-
|
|
572
|
+
replaceTurnMessages(
|
|
573
|
+
state.messages,
|
|
574
|
+
run.runId,
|
|
575
|
+
[...sendMessages(run), assistantMessage(run, notification)],
|
|
576
|
+
run.admittedAt,
|
|
526
577
|
);
|
|
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
578
|
|
|
535
579
|
activeRun = activeRunView(run) ?? activeRun;
|
|
536
580
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
@@ -2638,19 +2682,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2638
2682
|
message.runId = result.runId;
|
|
2639
2683
|
message.id = `${result.runId}:${message.role}`;
|
|
2640
2684
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2685
|
+
// Projected exactly as the durable read projects it, from the same
|
|
2686
|
+
// events: one bubble per send, in order, and the model's own text
|
|
2687
|
+
// after them (issue 153). The POST's copy of a Turn and the transcript
|
|
2688
|
+
// read must draw the same thread, or the reply rearranges itself on
|
|
2689
|
+
// the next reload.
|
|
2690
|
+
const settled: ClientRun = {
|
|
2643
2691
|
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,
|
|
2692
|
+
input: text,
|
|
2650
2693
|
status: "completed",
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
}
|
|
2694
|
+
responseText: result.text,
|
|
2695
|
+
events: result.events,
|
|
2696
|
+
};
|
|
2697
|
+
replaceTurnMessages(
|
|
2698
|
+
web.value.messages,
|
|
2699
|
+
result.runId,
|
|
2700
|
+
[...sendMessages(settled), assistantMessage(settled, undefined)],
|
|
2701
|
+
optimisticAt,
|
|
2702
|
+
);
|
|
2654
2703
|
try {
|
|
2655
2704
|
await deliverNotifications(botId, generation);
|
|
2656
2705
|
} catch (error) {
|
|
@@ -2709,16 +2758,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2709
2758
|
message.runId === pendingRunId && message.role === "user",
|
|
2710
2759
|
)?.at ?? optimisticAt,
|
|
2711
2760
|
);
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2761
|
+
replaceTurnMessages(
|
|
2762
|
+
web.value.messages,
|
|
2763
|
+
pendingRunId,
|
|
2764
|
+
[
|
|
2765
|
+
{
|
|
2766
|
+
id: `${pendingRunId}:assistant`,
|
|
2767
|
+
runId: pendingRunId,
|
|
2768
|
+
role: "assistant",
|
|
2769
|
+
text: "Checking whether your message went through…",
|
|
2770
|
+
at: placeholderAt,
|
|
2771
|
+
status: "interrupted",
|
|
2772
|
+
tools: [],
|
|
2773
|
+
sends: [],
|
|
2774
|
+
},
|
|
2775
|
+
],
|
|
2776
|
+
placeholderAt,
|
|
2777
|
+
);
|
|
2722
2778
|
if (aborted) {
|
|
2723
2779
|
web.value.error = undefined;
|
|
2724
2780
|
} else {
|
|
@@ -2744,17 +2800,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2744
2800
|
// have to improvise — and with the Turn no longer running, so Stop
|
|
2745
2801
|
// stops standing for a Turn nobody is executing.
|
|
2746
2802
|
if (disposition === "unreachable") {
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2803
|
+
replaceTurnMessages(
|
|
2804
|
+
web.value.messages,
|
|
2805
|
+
pendingRunId,
|
|
2806
|
+
[
|
|
2807
|
+
{
|
|
2808
|
+
id: `${pendingRunId}:assistant`,
|
|
2809
|
+
runId: pendingRunId,
|
|
2810
|
+
role: "assistant",
|
|
2811
|
+
text: UNREACHABLE_BOT_MESSAGE_V1,
|
|
2812
|
+
at: placeholderAt,
|
|
2813
|
+
status: "error",
|
|
2814
|
+
retry: "resend",
|
|
2815
|
+
tools: [],
|
|
2816
|
+
sends: [],
|
|
2817
|
+
},
|
|
2818
|
+
],
|
|
2819
|
+
placeholderAt,
|
|
2820
|
+
);
|
|
2758
2821
|
// The bubble is the report, and it is the one carrying the Retry.
|
|
2759
2822
|
// Saying the same sentence again in the banner above it is what the
|
|
2760
2823
|
// thread already looked like when it was broken — the same string
|
|
@@ -2774,19 +2837,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2774
2837
|
// retry, and one system line says so.
|
|
2775
2838
|
const notAdmitted =
|
|
2776
2839
|
"Your message didn't go through. Try sending it again.";
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2840
|
+
replaceTurnMessages(
|
|
2841
|
+
web.value.messages,
|
|
2842
|
+
pendingRunId,
|
|
2843
|
+
[
|
|
2844
|
+
{
|
|
2845
|
+
id: `${pendingRunId}:assistant`,
|
|
2846
|
+
runId: pendingRunId,
|
|
2847
|
+
role: "system",
|
|
2848
|
+
text: notAdmitted,
|
|
2849
|
+
at: placeholderAt,
|
|
2850
|
+
status: "error",
|
|
2851
|
+
// The same affordance an unreachable send gets: the draft is
|
|
2852
|
+
// back in the composer, and this sends it again.
|
|
2853
|
+
retry: "resend",
|
|
2854
|
+
tools: [],
|
|
2855
|
+
sends: [],
|
|
2856
|
+
},
|
|
2857
|
+
],
|
|
2858
|
+
placeholderAt,
|
|
2859
|
+
);
|
|
2790
2860
|
return { accepted: false, error: notAdmitted };
|
|
2791
2861
|
}
|
|
2792
2862
|
return { accepted: true, runId: pendingRunId };
|
|
@@ -3081,15 +3151,46 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3081
3151
|
];
|
|
3082
3152
|
};
|
|
3083
3153
|
|
|
3084
|
-
|
|
3154
|
+
/**
|
|
3155
|
+
* Puts a Turn's Bot-side lines in the thread, in order, in one place.
|
|
3156
|
+
*
|
|
3157
|
+
* A Turn is one user message and then however many the Bot sent, so a merge
|
|
3158
|
+
* cannot key one bubble by run id and overwrite it — that is exactly how a
|
|
3159
|
+
* second `send_to_user` used to replace the first. Every Bot-side line the
|
|
3160
|
+
* run already has is lifted out and the new ones go back at the same
|
|
3161
|
+
* position, which keeps the sends in their durable order and keeps the whole
|
|
3162
|
+
* Turn together between the Turn before it and the Turn after it.
|
|
3163
|
+
*
|
|
3164
|
+
* Where a line already carried a timestamp and the caller offers none, the
|
|
3165
|
+
* one it had is kept: the thread sorts by time and a line must not jump.
|
|
3166
|
+
*/
|
|
3167
|
+
function replaceTurnMessages(
|
|
3085
3168
|
messages: WebChatMessage[],
|
|
3086
3169
|
runId: string,
|
|
3087
|
-
|
|
3170
|
+
replacements: WebChatMessage[],
|
|
3171
|
+
at?: string,
|
|
3088
3172
|
): void {
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
)
|
|
3092
|
-
|
|
3173
|
+
let start = -1;
|
|
3174
|
+
let existingAt: string | undefined;
|
|
3175
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
3176
|
+
const message = messages[index];
|
|
3177
|
+
if (!message || message.runId !== runId || message.role !== "assistant")
|
|
3178
|
+
continue;
|
|
3179
|
+
existingAt = message.at ?? existingAt;
|
|
3180
|
+
start = index;
|
|
3181
|
+
messages.splice(index, 1);
|
|
3182
|
+
}
|
|
3183
|
+
if (start < 0) {
|
|
3184
|
+
// No Bot-side line yet: it belongs directly after this Turn's prompt, so
|
|
3185
|
+
// an older Turn's reply can never come between them.
|
|
3186
|
+
const userIndex = messages.findIndex(
|
|
3187
|
+
(message) => message.runId === runId && message.role === "user",
|
|
3188
|
+
);
|
|
3189
|
+
start = userIndex >= 0 ? userIndex + 1 : messages.length;
|
|
3190
|
+
}
|
|
3191
|
+
const stamp = at ?? existingAt;
|
|
3192
|
+
if (stamp) for (const message of replacements) message.at = stamp;
|
|
3193
|
+
messages.splice(start, 0, ...replacements);
|
|
3093
3194
|
}
|
|
3094
3195
|
|
|
3095
3196
|
/** Takes back both optimistic lines of a send the Bot never admitted. */
|
package/src/client/styles.css
CHANGED
|
@@ -360,11 +360,13 @@
|
|
|
360
360
|
* notices and sends stack inside it, so a one-word reply is a bubble the width
|
|
361
361
|
* of its word rather than a sliver of a shared row.
|
|
362
362
|
*
|
|
363
|
-
* The avatar is not in that column
|
|
364
|
-
* own row
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
363
|
+
* The avatar is not in that column, and not in the article at all: while the
|
|
364
|
+
* Bot is working it sits on its own row at the very end of the thread, so the
|
|
365
|
+
* trail streaming off its right has the width of the transcript to run through
|
|
366
|
+
* rather than the gutter beside a bubble, and a message sent mid-Turn lands
|
|
367
|
+
* above it. Every reply here is from the same Bot — there are no group
|
|
368
|
+
* conversations — so a sheep on every settled line said nothing and cost the
|
|
369
|
+
* column its left edge.
|
|
368
370
|
*/
|
|
369
371
|
.message-assistant {
|
|
370
372
|
flex-direction: column;
|
|
@@ -383,11 +385,16 @@
|
|
|
383
385
|
/*
|
|
384
386
|
* The working row. Its height is the trail's canvas: tall enough for the
|
|
385
387
|
* wobble to be visible, short enough that it reads as one line of the thread.
|
|
388
|
+
* It is the thread's last child, so it takes the same top margin every message
|
|
389
|
+
* has and adds no width of its own — the bubbles above never move sideways
|
|
390
|
+
* when it appears or goes.
|
|
386
391
|
*/
|
|
387
392
|
.bot-working {
|
|
388
393
|
display: flex;
|
|
394
|
+
width: 100%;
|
|
389
395
|
height: 44px;
|
|
390
396
|
align-items: center;
|
|
397
|
+
margin-top: 6px;
|
|
391
398
|
gap: 0;
|
|
392
399
|
}
|
|
393
400
|
|
|
@@ -461,62 +468,6 @@
|
|
|
461
468
|
}
|
|
462
469
|
}
|
|
463
470
|
|
|
464
|
-
@keyframes frock-halo {
|
|
465
|
-
0%,
|
|
466
|
-
100% {
|
|
467
|
-
opacity: 0;
|
|
468
|
-
transform: scale(0.94);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
50% {
|
|
472
|
-
opacity: 1;
|
|
473
|
-
transform: scale(1.06);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/*
|
|
478
|
-
* The bubble a Turn has before it has words: three dots that say the Bot is
|
|
479
|
-
* working, where the reply itself will appear.
|
|
480
|
-
*/
|
|
481
|
-
.message-working {
|
|
482
|
-
display: flex;
|
|
483
|
-
align-items: center;
|
|
484
|
-
min-height: 20px;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
.working-dots {
|
|
488
|
-
display: inline-flex;
|
|
489
|
-
gap: 4px;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
.working-dots i {
|
|
493
|
-
width: 6px;
|
|
494
|
-
height: 6px;
|
|
495
|
-
border-radius: 50%;
|
|
496
|
-
background: var(--frock-text-muted);
|
|
497
|
-
animation: frock-working-dot 1.2s ease-in-out infinite;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
.working-dots i:nth-child(2) {
|
|
501
|
-
animation-delay: 0.15s;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
.working-dots i:nth-child(3) {
|
|
505
|
-
animation-delay: 0.3s;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
@keyframes frock-working-dot {
|
|
509
|
-
0%,
|
|
510
|
-
60%,
|
|
511
|
-
100% {
|
|
512
|
-
opacity: 0.3;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
30% {
|
|
516
|
-
opacity: 1;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
471
|
/*
|
|
521
472
|
* A bubble runs nearly the width of the transcript, leaving one clear margin
|
|
522
473
|
* on the side it is not anchored to: the Bot's at the end, the User's at the
|
package/src/notification-id.ts
CHANGED
|
Binary file
|