@frockbot/plugin-shell 0.3.16 → 0.3.18
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/frockbot.json +1 -1
- package/package.json +33 -32
- package/src/agent.test.ts +85 -2
- package/src/agent.ts +87 -20
- package/src/backend-applets.ts +2 -2
- package/src/backend-composition.ts +20 -0
- package/src/backend-flock.ts +119 -10
- package/src/backend-recovery-integration.test.ts +1 -1
- package/src/backend-runner.ts +1 -0
- package/src/backend.ts +100 -1
- package/src/client/FrockBotApp.vue +6 -1
- package/src/client/index.test.ts +61 -0
- package/src/client/index.ts +43 -10
- package/src/client/mobile-safe-area.test.ts +25 -0
- package/src/client/styles.css +28 -0
- package/src/client/voice-microphone.ts +13 -3
- package/src/compaction.test.ts +26 -0
- package/src/compaction.ts +46 -14
- package/src/history.ts +8 -2
- package/src/run-failure-copy.test.ts +11 -0
- package/src/run-failure-copy.ts +5 -1
- package/src/run-protocol.test.ts +73 -4
- package/src/run-protocol.ts +118 -12
- package/src/shared.ts +3 -0
package/src/backend.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
decodeIsolateWorkspaceListRequestV1,
|
|
10
10
|
decodeIsolateWorkspacePathV1,
|
|
11
11
|
decodeIsolateWorkspaceWriteRequestV1,
|
|
12
|
+
decodeSendToUserPayloadV1,
|
|
12
13
|
decodeWorkspacePathV1,
|
|
13
14
|
decodeWorkspaceRootV1,
|
|
14
15
|
decodeSessionEvent,
|
|
@@ -134,6 +135,7 @@ import {
|
|
|
134
135
|
import {
|
|
135
136
|
createAppletCapabilityHostV1,
|
|
136
137
|
createAppletInstanceBindingV1,
|
|
138
|
+
APPLET_DIST_FILES_V1,
|
|
137
139
|
appletRpcSnapshotV1 as rpcJsonSnapshotV1,
|
|
138
140
|
resolveAppletCompositionV1,
|
|
139
141
|
type AppletCapabilityHostV1,
|
|
@@ -253,6 +255,7 @@ import {
|
|
|
253
255
|
decodeSubagentSlotReceiptV1,
|
|
254
256
|
type SubagentSlotBinding,
|
|
255
257
|
} from "@frockbot/plugin-subagents/quota";
|
|
258
|
+
import { decodeAgentTurnSlotReceiptV1 } from "@frockbot/plugin-flock/quota";
|
|
256
259
|
import {
|
|
257
260
|
subagentModelCatalogV1,
|
|
258
261
|
type SubagentModelOptionV1,
|
|
@@ -406,6 +409,7 @@ import {
|
|
|
406
409
|
decodeClientRunLookupQueryV1,
|
|
407
410
|
decodeClientRunListQueryV1,
|
|
408
411
|
decodeClientRunStopCommandV1,
|
|
412
|
+
decodeClientTurnV1,
|
|
409
413
|
isVisibleRunV1,
|
|
410
414
|
projectClientRunLookupV1,
|
|
411
415
|
projectClientRunV1,
|
|
@@ -667,6 +671,16 @@ export interface ShellBotBackendHost {
|
|
|
667
671
|
scheduledWorkInFlight?(): boolean;
|
|
668
672
|
deferScheduledWork?(transaction: DurableObjectTransaction): Promise<void>;
|
|
669
673
|
settleScheduledWork?(): Promise<void>;
|
|
674
|
+
/**
|
|
675
|
+
* A derived accounting projection after `turn/end` is durable. The host
|
|
676
|
+
* queues it before delivery, so failure never changes the Turn's outcome.
|
|
677
|
+
*/
|
|
678
|
+
recordSettledUsage?(input: {
|
|
679
|
+
botId: string;
|
|
680
|
+
runId: string;
|
|
681
|
+
turn: number;
|
|
682
|
+
events: readonly SessionEvent[];
|
|
683
|
+
}): Promise<void>;
|
|
670
684
|
}
|
|
671
685
|
|
|
672
686
|
/** The narrow storage seam the Bot's announcement log is written through. */
|
|
@@ -769,6 +783,7 @@ export class ShellBotBackendContribution {
|
|
|
769
783
|
private readonly hostScheduledWorkInFlight?: ShellBotBackendHost["scheduledWorkInFlight"];
|
|
770
784
|
private readonly hostDeferScheduledWork?: ShellBotBackendHost["deferScheduledWork"];
|
|
771
785
|
private readonly hostSettleScheduledWork?: ShellBotBackendHost["settleScheduledWork"];
|
|
786
|
+
private readonly recordSettledUsage?: ShellBotBackendHost["recordSettledUsage"];
|
|
772
787
|
|
|
773
788
|
constructor(host: ShellBotBackendHost) {
|
|
774
789
|
this.ctx = host.state;
|
|
@@ -784,6 +799,7 @@ export class ShellBotBackendContribution {
|
|
|
784
799
|
this.hostScheduledWorkInFlight = host.scheduledWorkInFlight;
|
|
785
800
|
this.hostDeferScheduledWork = host.deferScheduledWork;
|
|
786
801
|
this.hostSettleScheduledWork = host.settleScheduledWork;
|
|
802
|
+
this.recordSettledUsage = host.recordSettledUsage;
|
|
787
803
|
const routines = createBotRoutines(
|
|
788
804
|
host.state.storage,
|
|
789
805
|
createBotRoutineHookMinter(
|
|
@@ -1645,6 +1661,9 @@ export class ShellBotBackendContribution {
|
|
|
1645
1661
|
// One admitted Turn is one run; the Turn ordinal lives in the session log.
|
|
1646
1662
|
turnId: input.command.runId,
|
|
1647
1663
|
sessionId: input.command.sessionId,
|
|
1664
|
+
// The admitted configuration snapshot is durable, so a recovered
|
|
1665
|
+
// bot_message reuses the same sender display name in its target command.
|
|
1666
|
+
fromBotName: settings.profile.name,
|
|
1648
1667
|
// The pin this Turn was admitted under, and the type it was admitted as.
|
|
1649
1668
|
// A subagent dispatched from here runs on this generation, and the model
|
|
1650
1669
|
// catalog it is offered is narrowed by this turn type.
|
|
@@ -1660,6 +1679,17 @@ export class ShellBotBackendContribution {
|
|
|
1660
1679
|
...(input.command.origin?.kind === "subagent"
|
|
1661
1680
|
? { subagentTaskId: input.command.origin.taskId }
|
|
1662
1681
|
: {}),
|
|
1682
|
+
...(input.command.origin?.kind === "bot"
|
|
1683
|
+
? {
|
|
1684
|
+
inboundAgent: {
|
|
1685
|
+
kind: "bot" as const,
|
|
1686
|
+
fromBotId: input.command.origin.fromBotId,
|
|
1687
|
+
fromBotName: input.command.origin.fromBotName,
|
|
1688
|
+
},
|
|
1689
|
+
}
|
|
1690
|
+
: input.command.origin?.kind === "voice"
|
|
1691
|
+
? { inboundAgent: { kind: "voice" as const } }
|
|
1692
|
+
: {}),
|
|
1663
1693
|
};
|
|
1664
1694
|
let mountedRoot: ShellMountedComposition["root"] | undefined;
|
|
1665
1695
|
let mountedGeneration: CompositionGenerationV1 | undefined;
|
|
@@ -1729,6 +1759,16 @@ export class ShellBotBackendContribution {
|
|
|
1729
1759
|
input.command.sessionId,
|
|
1730
1760
|
effect,
|
|
1731
1761
|
),
|
|
1762
|
+
...(this.recordSettledUsage
|
|
1763
|
+
? {
|
|
1764
|
+
onTurnStopping: (settled) =>
|
|
1765
|
+
this.recordSettledUsage!({
|
|
1766
|
+
botId: input.identity.botId,
|
|
1767
|
+
runId: input.command.runId,
|
|
1768
|
+
...settled,
|
|
1769
|
+
}),
|
|
1770
|
+
}
|
|
1771
|
+
: {}),
|
|
1732
1772
|
...(isolate ? { isolate } : {}),
|
|
1733
1773
|
...(appletRouting ? { applets: appletRouting } : {}),
|
|
1734
1774
|
}).mount(mounting, signal);
|
|
@@ -2414,7 +2454,7 @@ export class ShellBotBackendContribution {
|
|
|
2414
2454
|
// User with no Computer assignment has no root to pull, and the Bot that
|
|
2415
2455
|
// just built on its Computer has it open already.
|
|
2416
2456
|
syncSourceRootNow: active
|
|
2417
|
-
? async () => {
|
|
2457
|
+
? async (appletId) => {
|
|
2418
2458
|
const root = active.mounted.runtime.root as unknown as {
|
|
2419
2459
|
computers?: ComputerRegistry;
|
|
2420
2460
|
sessions: typeof active.mounted.runtime.root.sessions;
|
|
@@ -2437,6 +2477,9 @@ export class ShellBotBackendContribution {
|
|
|
2437
2477
|
sessionId: active.sessionId,
|
|
2438
2478
|
turn,
|
|
2439
2479
|
root: appletsSourceRootV1(identity.userId),
|
|
2480
|
+
requiredPaths: APPLET_DIST_FILES_V1.map(
|
|
2481
|
+
(path) => `${appletId}/${path}`,
|
|
2482
|
+
),
|
|
2440
2483
|
signal: active.signal,
|
|
2441
2484
|
});
|
|
2442
2485
|
}
|
|
@@ -3251,6 +3294,22 @@ export class ShellBotBackendContribution {
|
|
|
3251
3294
|
};
|
|
3252
3295
|
}
|
|
3253
3296
|
|
|
3297
|
+
/** Narrow RPC for the User-wide agent-lane concurrency lease. */
|
|
3298
|
+
private agentTurnSlots(identity: BotIdentity) {
|
|
3299
|
+
const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
|
|
3300
|
+
const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
|
|
3301
|
+
reserveAgentTurnSlot(input: unknown): Promise<unknown>;
|
|
3302
|
+
releaseAgentTurnSlot(input: unknown): Promise<unknown>;
|
|
3303
|
+
};
|
|
3304
|
+
return {
|
|
3305
|
+
reserve: async (request: unknown) =>
|
|
3306
|
+
decodeAgentTurnSlotReceiptV1(await rpc.reserveAgentTurnSlot(request)),
|
|
3307
|
+
release: async (request: unknown) => {
|
|
3308
|
+
await rpc.releaseAgentTurnSlot(request);
|
|
3309
|
+
},
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3254
3313
|
/**
|
|
3255
3314
|
* The User-wide `desktop-gui` lease, held at the Computer host.
|
|
3256
3315
|
*
|
|
@@ -4333,6 +4392,7 @@ export class ShellBotBackendContribution {
|
|
|
4333
4392
|
runId: string;
|
|
4334
4393
|
turnId: string;
|
|
4335
4394
|
sessionId: string;
|
|
4395
|
+
fromBotName: string;
|
|
4336
4396
|
/**
|
|
4337
4397
|
* The generation this Turn pinned, and the type it was admitted as. A
|
|
4338
4398
|
* dispatched subagent runs on the generation its parent pinned, and the
|
|
@@ -4522,6 +4582,39 @@ export class ShellBotBackendContribution {
|
|
|
4522
4582
|
this.userConfiguration(identity).listBots(userId),
|
|
4523
4583
|
createBot: (userId, command) =>
|
|
4524
4584
|
this.userConfiguration(identity).createBot(userId, command),
|
|
4585
|
+
reserveAgentTurn: (request) =>
|
|
4586
|
+
this.agentTurnSlots(identity).reserve(request),
|
|
4587
|
+
releaseAgentTurn: (request) =>
|
|
4588
|
+
this.agentTurnSlots(identity).release(request),
|
|
4589
|
+
runAgent: async (request) => {
|
|
4590
|
+
if (!this.env.BOT_STATES) {
|
|
4591
|
+
throw new Error("Bot-to-Bot messaging is unavailable");
|
|
4592
|
+
}
|
|
4593
|
+
const id = this.env.BOT_STATES.idFromName(
|
|
4594
|
+
`${request.userId}:${request.botId}`,
|
|
4595
|
+
);
|
|
4596
|
+
const rpc = this.env.BOT_STATES.get(id) as unknown as {
|
|
4597
|
+
runAgent(input: unknown): Promise<unknown>;
|
|
4598
|
+
};
|
|
4599
|
+
const completed = decodeClientTurnV1(
|
|
4600
|
+
structuredClone(await rpc.runAgent(request)),
|
|
4601
|
+
);
|
|
4602
|
+
let sentText: string | undefined;
|
|
4603
|
+
for (const event of completed.events) {
|
|
4604
|
+
if (event.type !== "send/to-user") continue;
|
|
4605
|
+
const payload = decodeSendToUserPayloadV1(
|
|
4606
|
+
event.payload,
|
|
4607
|
+
"agent send/to-user payload",
|
|
4608
|
+
);
|
|
4609
|
+
if (payload.type === "text") {
|
|
4610
|
+
sentText = payload.text;
|
|
4611
|
+
break;
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
return {
|
|
4615
|
+
text: sentText ?? completed.text,
|
|
4616
|
+
};
|
|
4617
|
+
},
|
|
4525
4618
|
}),
|
|
4526
4619
|
}
|
|
4527
4620
|
: {}),
|
|
@@ -5209,6 +5302,12 @@ export class ShellBotBackendContribution {
|
|
|
5209
5302
|
};
|
|
5210
5303
|
}
|
|
5211
5304
|
|
|
5305
|
+
/** Count inputs waiting for the next conversational Turn without draining them. */
|
|
5306
|
+
async pendingInputCount(identity: BotIdentity): Promise<number> {
|
|
5307
|
+
await this.validateIdentity(identity);
|
|
5308
|
+
return (await this.routineInbox.pending()).length;
|
|
5309
|
+
}
|
|
5310
|
+
|
|
5212
5311
|
/**
|
|
5213
5312
|
* Acknowledge inbox entries. An explicit command, never a side effect of
|
|
5214
5313
|
* reading: a background poll must not clear the badge.
|
|
@@ -1490,7 +1490,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1490
1490
|
</div>
|
|
1491
1491
|
</div>
|
|
1492
1492
|
</template>
|
|
1493
|
-
<div v-else class="message-
|
|
1493
|
+
<div v-else class="message-user-column">
|
|
1494
|
+
<div class="message-bubble">{{ message.text }}</div>
|
|
1495
|
+
<span v-if="message.via" class="message-via">
|
|
1496
|
+
via {{ message.via.name }}
|
|
1497
|
+
</span>
|
|
1498
|
+
</div>
|
|
1494
1499
|
</article>
|
|
1495
1500
|
<!--
|
|
1496
1501
|
The working row: the Bot's own avatar on its own line at the end of
|
package/src/client/index.test.ts
CHANGED
|
@@ -95,6 +95,40 @@ afterEach(() => {
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("a degraded Computer sync is a notice beneath the Turn once", () => {
|
|
99
|
+
const state: Pick<
|
|
100
|
+
FrockBotWebData,
|
|
101
|
+
"messages" | "activeRunId" | "runningRunId" | "activeRun"
|
|
102
|
+
> = { messages: [] };
|
|
103
|
+
|
|
104
|
+
projectDurableRuns(
|
|
105
|
+
state,
|
|
106
|
+
[],
|
|
107
|
+
[
|
|
108
|
+
{
|
|
109
|
+
runId: "run-sync",
|
|
110
|
+
input: "build the applet",
|
|
111
|
+
status: "completed",
|
|
112
|
+
responseText: "The Applet is ready.",
|
|
113
|
+
events: [
|
|
114
|
+
{
|
|
115
|
+
type: "computer/sync",
|
|
116
|
+
status: "degraded",
|
|
117
|
+
message: "Excluded 1 reproducible Workspace item from sync.",
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(state.messages).toHaveLength(2);
|
|
125
|
+
expect(state.messages[1]).toMatchObject({
|
|
126
|
+
role: "assistant",
|
|
127
|
+
text: "The Applet is ready.",
|
|
128
|
+
notice: "Excluded 1 reproducible Workspace item from sync.",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
98
132
|
describe("application manifest protocol", () => {
|
|
99
133
|
const emptyManifest = {
|
|
100
134
|
schemaVersion: 1,
|
|
@@ -1451,6 +1485,33 @@ describe("active durable Turn projection", () => {
|
|
|
1451
1485
|
expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
|
|
1452
1486
|
});
|
|
1453
1487
|
|
|
1488
|
+
test("keeps the source marker on an agent-origin user bubble", () => {
|
|
1489
|
+
const state: Pick<
|
|
1490
|
+
FrockBotWebData,
|
|
1491
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1492
|
+
> = { messages: [] };
|
|
1493
|
+
projectDurableRuns(
|
|
1494
|
+
state,
|
|
1495
|
+
[],
|
|
1496
|
+
[
|
|
1497
|
+
{
|
|
1498
|
+
runId: "run-agent",
|
|
1499
|
+
input: "What changed?",
|
|
1500
|
+
events: [],
|
|
1501
|
+
status: "completed",
|
|
1502
|
+
responseText: "The answer",
|
|
1503
|
+
via: { kind: "bot", name: "Researcher", botId: "researcher" },
|
|
1504
|
+
},
|
|
1505
|
+
],
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1508
|
+
expect(state.messages[0]).toMatchObject({
|
|
1509
|
+
role: "user",
|
|
1510
|
+
text: "What changed?",
|
|
1511
|
+
via: { kind: "bot", name: "Researcher" },
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1454
1515
|
test("projects dispatched subagents as chips, and skips one it cannot draw", () => {
|
|
1455
1516
|
const state: Pick<
|
|
1456
1517
|
FrockBotWebData,
|
package/src/client/index.ts
CHANGED
|
@@ -363,6 +363,13 @@ function assistantMessage(
|
|
|
363
363
|
run: ClientRun,
|
|
364
364
|
notification: ClientNotificationIntent | undefined,
|
|
365
365
|
): WebChatMessage {
|
|
366
|
+
const syncNotice = run.events.find(
|
|
367
|
+
(event) => event.type === "computer/sync" && event.message,
|
|
368
|
+
)?.message;
|
|
369
|
+
const notice = (primary?: string): string | undefined =>
|
|
370
|
+
[primary, syncNotice]
|
|
371
|
+
.filter((part): part is string => Boolean(part))
|
|
372
|
+
.join(" ") || undefined;
|
|
366
373
|
if (run.status === "running") {
|
|
367
374
|
// A streaming Turn carries only the text the model has produced. Until
|
|
368
375
|
// there is any, the thread shows the animated avatar and no bubble.
|
|
@@ -375,6 +382,7 @@ function assistantMessage(
|
|
|
375
382
|
// A Turn that has not started shows nothing of its own: the greyed user
|
|
376
383
|
// message is the whole of what the thread says about it.
|
|
377
384
|
...(run.queued ? { pending: true } : {}),
|
|
385
|
+
...(syncNotice ? { notice: syncNotice } : {}),
|
|
378
386
|
tools: toolsFrom(run.events),
|
|
379
387
|
sends: [],
|
|
380
388
|
tasks: tasksFrom(run.events),
|
|
@@ -391,6 +399,7 @@ function assistantMessage(
|
|
|
391
399
|
role: "assistant",
|
|
392
400
|
text: visibleAssistantText(run),
|
|
393
401
|
status: "aborted",
|
|
402
|
+
...(syncNotice ? { notice: syncNotice } : {}),
|
|
394
403
|
tools: toolsFrom(run.events),
|
|
395
404
|
sends: [],
|
|
396
405
|
tasks: tasksFrom(run.events),
|
|
@@ -409,7 +418,7 @@ function assistantMessage(
|
|
|
409
418
|
* exactly like the Bot speaking.
|
|
410
419
|
*/
|
|
411
420
|
text: visibleAssistantText(run),
|
|
412
|
-
notice: "This reply stopped partway. Try again to continue it.",
|
|
421
|
+
notice: notice("This reply stopped partway. Try again to continue it."),
|
|
413
422
|
status: "reconciliation-required",
|
|
414
423
|
tools: toolsFrom(run.events),
|
|
415
424
|
sends: [],
|
|
@@ -422,7 +431,7 @@ function assistantMessage(
|
|
|
422
431
|
runId: run.runId,
|
|
423
432
|
role: "assistant",
|
|
424
433
|
text: visibleAssistantText(run),
|
|
425
|
-
notice: "You stopped this.",
|
|
434
|
+
notice: notice("You stopped this."),
|
|
426
435
|
status: "aborted",
|
|
427
436
|
tools: toolsFrom(run.events),
|
|
428
437
|
sends: [],
|
|
@@ -447,7 +456,7 @@ function assistantMessage(
|
|
|
447
456
|
// wire, so this keeps whatever that chose — the model-deadline copy says
|
|
448
457
|
// something the outcome alone cannot — and falls back to the same line a
|
|
449
458
|
// reply-less failure gets.
|
|
450
|
-
notice: knownFailureCopyV1(run.failure),
|
|
459
|
+
notice: notice(knownFailureCopyV1(run.failure)),
|
|
451
460
|
status: "error",
|
|
452
461
|
tools: toolsFrom(run.events),
|
|
453
462
|
sends: [],
|
|
@@ -465,8 +474,10 @@ function assistantMessage(
|
|
|
465
474
|
// Why the Turn ends there, under whatever it had already said — never as
|
|
466
475
|
// the bubble's own text, which reads as the Bot saying it.
|
|
467
476
|
...(run.status === "failed"
|
|
468
|
-
? { notice: knownFailureCopyV1(run.failure) }
|
|
469
|
-
:
|
|
477
|
+
? { notice: notice(knownFailureCopyV1(run.failure)) }
|
|
478
|
+
: syncNotice
|
|
479
|
+
? { notice: syncNotice }
|
|
480
|
+
: {}),
|
|
470
481
|
status: run.status === "failed" ? "error" : "completed",
|
|
471
482
|
tools: toolsFrom(run.events),
|
|
472
483
|
sends: [],
|
|
@@ -561,6 +572,7 @@ export function projectDurableRuns(
|
|
|
561
572
|
? { at: existingUser.at }
|
|
562
573
|
: {}),
|
|
563
574
|
status: "completed",
|
|
575
|
+
...(run.via ? { via: run.via } : {}),
|
|
564
576
|
// Greyed while its Turn waits, ordinary the moment it is running. The
|
|
565
577
|
// flag comes from durable run state, so a reload draws the same thing.
|
|
566
578
|
...(run.queued ? { pending: true } : {}),
|
|
@@ -3070,6 +3082,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3070
3082
|
* and the one-bubble-per-send contract is untouched.
|
|
3071
3083
|
*/
|
|
3072
3084
|
let stopRunChannel: (() => void) | undefined;
|
|
3085
|
+
// The channel's own health, for the observation below: `fallback` means the
|
|
3086
|
+
// socket is gone and the transcript is flying blind, and each return to
|
|
3087
|
+
// `open` means it was gone for a while. Either is a reason to read the
|
|
3088
|
+
// running Turn from authority rather than trust a POST that may have died
|
|
3089
|
+
// with the same connection — which is how a phone kept drawing the working
|
|
3090
|
+
// trail for a Turn the server had already settled (2026-09-04).
|
|
3091
|
+
const channelFallback = ref(false);
|
|
3092
|
+
const channelReconnects = ref(0);
|
|
3073
3093
|
const stopRunChannelWatch = watch(
|
|
3074
3094
|
() => web.value.activeBotId,
|
|
3075
3095
|
(botId) => {
|
|
@@ -3096,10 +3116,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3096
3116
|
restoredWithoutRead = undefined;
|
|
3097
3117
|
await deliverNotifications(botId, generation);
|
|
3098
3118
|
},
|
|
3099
|
-
status() {
|
|
3119
|
+
status(status) {
|
|
3100
3120
|
// The channel's health is not the transcript's: an unavailable
|
|
3101
3121
|
// socket falls back to the observation below, which is what a
|
|
3102
3122
|
// client without one uses anyway.
|
|
3123
|
+
if (generation !== selectionGeneration) return;
|
|
3124
|
+
const wasDown = channelFallback.value;
|
|
3125
|
+
channelFallback.value = status === "fallback";
|
|
3126
|
+
if (status === "open" && wasDown) channelReconnects.value += 1;
|
|
3103
3127
|
},
|
|
3104
3128
|
});
|
|
3105
3129
|
},
|
|
@@ -3107,12 +3131,21 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
3107
3131
|
);
|
|
3108
3132
|
|
|
3109
3133
|
const stopRunObservation = watch(
|
|
3110
|
-
() =>
|
|
3111
|
-
|
|
3134
|
+
() =>
|
|
3135
|
+
[
|
|
3136
|
+
web.value.activeBotId,
|
|
3137
|
+
web.value.activeRunId,
|
|
3138
|
+
channelFallback.value,
|
|
3139
|
+
channelReconnects.value,
|
|
3140
|
+
] as const,
|
|
3141
|
+
([botId, runId, fallback]) => {
|
|
3112
3142
|
// The send path owns the run it started: its POST is the observation,
|
|
3113
3143
|
// and `stopRun` starts its own. This is for every other way a client
|
|
3114
|
-
// finds itself watching a Turn it is not holding open
|
|
3115
|
-
|
|
3144
|
+
// finds itself watching a Turn it is not holding open — and, once the
|
|
3145
|
+
// state channel has dropped or come back, for the Turn it *is* holding
|
|
3146
|
+
// open, because that POST shared the connection that just failed.
|
|
3147
|
+
if (!botId || !runId || runObserver) return;
|
|
3148
|
+
if (activeRequest && !fallback && channelReconnects.value === 0) return;
|
|
3116
3149
|
const generation = selectionGeneration;
|
|
3117
3150
|
const observer = new AbortController();
|
|
3118
3151
|
runObserver = observer;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const styles = await Bun.file(new URL("./styles.css", import.meta.url)).text();
|
|
4
|
+
|
|
5
|
+
describe("phone safe-area layout", () => {
|
|
6
|
+
test("keeps the conversation header and thread below the status bar", () => {
|
|
7
|
+
expect(styles).toContain(
|
|
8
|
+
"height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));",
|
|
9
|
+
);
|
|
10
|
+
expect(styles).toContain("padding-top: calc(var(--frock-safe-top) + 0px);");
|
|
11
|
+
expect(styles).toContain(
|
|
12
|
+
"top: calc(var(--frock-titlebar-height) + var(--frock-safe-top));",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("keeps drawers, Settings, and the composer clear of both bars", () => {
|
|
17
|
+
expect(styles).toContain(
|
|
18
|
+
"padding-bottom: calc(10px + var(--frock-safe-bottom));",
|
|
19
|
+
);
|
|
20
|
+
expect(styles).toContain(
|
|
21
|
+
"padding-bottom: calc(16px + var(--frock-safe-bottom));",
|
|
22
|
+
);
|
|
23
|
+
expect(styles).toContain("bottom: calc(10px + var(--frock-safe-bottom));");
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/client/styles.css
CHANGED
|
@@ -499,6 +499,17 @@
|
|
|
499
499
|
background: var(--frock-action-primary);
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
.message-user-column {
|
|
503
|
+
display: grid;
|
|
504
|
+
justify-items: end;
|
|
505
|
+
gap: 4px;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.message-via {
|
|
509
|
+
color: var(--frock-text-muted);
|
|
510
|
+
font-size: var(--frock-text-xs);
|
|
511
|
+
}
|
|
512
|
+
|
|
502
513
|
/*
|
|
503
514
|
* User-facing sends. They stack under the Bot's avatar in the same column the
|
|
504
515
|
* text bubble occupies, so a Turn that only sent a widget still reads as the
|
|
@@ -1118,6 +1129,7 @@
|
|
|
1118
1129
|
/* `calc` so the declaration is a length the checker can type; `env` alone
|
|
1119
1130
|
is not one it knows. */
|
|
1120
1131
|
padding-top: calc(var(--frock-safe-top) + 0px);
|
|
1132
|
+
padding-bottom: calc(10px + var(--frock-safe-bottom));
|
|
1121
1133
|
box-shadow: var(--frock-shadow-panel);
|
|
1122
1134
|
transform: translateX(-100%);
|
|
1123
1135
|
visibility: hidden;
|
|
@@ -1189,12 +1201,27 @@
|
|
|
1189
1201
|
/* No window chrome to clear, but the panel toggle still sits at the
|
|
1190
1202
|
trailing edge, so the row ends before it rather than under it. */
|
|
1191
1203
|
.topbar {
|
|
1204
|
+
height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
|
|
1192
1205
|
gap: 8px;
|
|
1193
1206
|
padding: 0 52px 0 4px;
|
|
1207
|
+
padding-top: calc(var(--frock-safe-top) + 0px);
|
|
1194
1208
|
}
|
|
1195
1209
|
|
|
1196
1210
|
.window-actions {
|
|
1211
|
+
height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
|
|
1197
1212
|
padding: 0 8px;
|
|
1213
|
+
padding-top: calc(var(--frock-safe-top) + 0px);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
.right-panel-header,
|
|
1217
|
+
.panel-surface-header {
|
|
1218
|
+
height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
|
|
1219
|
+
padding-top: calc(var(--frock-safe-top) + 0px);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
.right-panel-body,
|
|
1223
|
+
.panel-surface-content {
|
|
1224
|
+
padding-bottom: calc(16px + var(--frock-safe-bottom));
|
|
1198
1225
|
}
|
|
1199
1226
|
|
|
1200
1227
|
.brand-mark {
|
|
@@ -1211,6 +1238,7 @@
|
|
|
1211
1238
|
* vertical anchors all clear the home indicator.
|
|
1212
1239
|
*/
|
|
1213
1240
|
.thread {
|
|
1241
|
+
top: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
|
|
1214
1242
|
bottom: calc(76px + var(--frock-safe-bottom));
|
|
1215
1243
|
padding: 12px 12px 20px;
|
|
1216
1244
|
}
|
|
@@ -21,10 +21,14 @@ export interface VoiceMicrophoneV1 {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface VoiceMicrophoneOptionsV1 {
|
|
24
|
-
/** One frame of PCM16, little-endian, mono,
|
|
24
|
+
/** One frame of PCM16, little-endian, mono, at `sampleRate`. */
|
|
25
25
|
audio(pcm16: ArrayBuffer): void;
|
|
26
26
|
/** Peak amplitude of the frame, 0…1, for the capture animation. */
|
|
27
27
|
level(value: number): void;
|
|
28
|
+
/** Defaults to dictation's 24 kHz. Voice assistant input uses 16 kHz. */
|
|
29
|
+
sampleRate?: number;
|
|
30
|
+
/** Defaults to 32 ms at the selected sample rate. */
|
|
31
|
+
frameSamples?: number;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** False on a platform with no microphone API at all; the button stays hidden. */
|
|
@@ -83,8 +87,14 @@ export async function startVoiceMicrophoneV1(
|
|
|
83
87
|
numberOfOutputs: 1,
|
|
84
88
|
outputChannelCount: [1],
|
|
85
89
|
processorOptions: {
|
|
86
|
-
targetRate: VOICE_CAPTURE_SAMPLE_RATE_V1,
|
|
87
|
-
frameSamples:
|
|
90
|
+
targetRate: options.sampleRate ?? VOICE_CAPTURE_SAMPLE_RATE_V1,
|
|
91
|
+
frameSamples:
|
|
92
|
+
options.frameSamples ??
|
|
93
|
+
Math.round(
|
|
94
|
+
FRAME_SAMPLES *
|
|
95
|
+
((options.sampleRate ?? VOICE_CAPTURE_SAMPLE_RATE_V1) /
|
|
96
|
+
VOICE_CAPTURE_SAMPLE_RATE_V1),
|
|
97
|
+
),
|
|
88
98
|
},
|
|
89
99
|
});
|
|
90
100
|
node.port.onmessage = (event: MessageEvent) => {
|
package/src/compaction.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
parseCompactionSummaryV1,
|
|
16
16
|
PRUNED_TOOL_RESULT_V1,
|
|
17
17
|
pruneToolOutputsV1,
|
|
18
|
+
renderCompactionSummaryV1,
|
|
18
19
|
runCompactionV1,
|
|
19
20
|
COMPACTION_TRIGGER_RATIO_V1,
|
|
20
21
|
} from "./compaction.js";
|
|
@@ -385,6 +386,31 @@ describe("injecting a compaction", () => {
|
|
|
385
386
|
});
|
|
386
387
|
|
|
387
388
|
describe("reading the summariser's answer", () => {
|
|
389
|
+
test("renders the validated fields into the durable heading format", () => {
|
|
390
|
+
expect(
|
|
391
|
+
renderCompactionSummaryV1({
|
|
392
|
+
summary: "Work on the Applet.",
|
|
393
|
+
decisions: ["Keep the first design."],
|
|
394
|
+
openItems: [],
|
|
395
|
+
identifiers: ["applet-9f2c"],
|
|
396
|
+
}),
|
|
397
|
+
).toBe(
|
|
398
|
+
[
|
|
399
|
+
"## Summary",
|
|
400
|
+
"Work on the Applet.",
|
|
401
|
+
"",
|
|
402
|
+
"## Decisions",
|
|
403
|
+
"- Keep the first design.",
|
|
404
|
+
"",
|
|
405
|
+
"## Open items",
|
|
406
|
+
"- none",
|
|
407
|
+
"",
|
|
408
|
+
"## Identifiers mentioned",
|
|
409
|
+
"- applet-9f2c",
|
|
410
|
+
].join("\n"),
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
388
414
|
test("lifts the identifiers out of their heading", () => {
|
|
389
415
|
const parsed = parseCompactionSummaryV1(
|
|
390
416
|
[
|
package/src/compaction.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ModelBindingSnapshot,
|
|
27
27
|
type Session,
|
|
28
28
|
type SessionEvent,
|
|
29
|
+
type StructuredOutputSchemaV1,
|
|
29
30
|
} from "@frockbot/kernel-contracts";
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -262,27 +263,58 @@ export function assessCompactionV1(input: {
|
|
|
262
263
|
* ids in passing, and the list is what the event stores.
|
|
263
264
|
*/
|
|
264
265
|
export const COMPACTION_SYSTEM_PROMPT_V1 = [
|
|
265
|
-
"You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt.
|
|
266
|
+
"You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt.",
|
|
266
267
|
"",
|
|
267
268
|
"CRITICAL: You MUST preserve ALL opaque identifiers exactly as they appear. That includes UUIDs, hashes, full URLs with their query parameters, file and Workspace paths, Package ids, Applet ids, Bot ids, Session ids, tool call ids, model names and version strings. Do NOT paraphrase, abbreviate, or generalise an identifier. Copy it exactly.",
|
|
268
269
|
"",
|
|
269
|
-
"
|
|
270
|
-
"",
|
|
271
|
-
"## Summary",
|
|
272
|
-
"What the conversation is about and what has happened, in a few short paragraphs or bullets.",
|
|
273
|
-
"",
|
|
274
|
-
"## Decisions",
|
|
275
|
-
"Decisions made and the reason for each. Where a decision was later changed, keep only the latest and say it superseded an earlier one.",
|
|
276
|
-
"",
|
|
277
|
-
"## Open items",
|
|
278
|
-
"Work that is pending, promised, or unfinished. Be specific about what is owed and by whom.",
|
|
279
|
-
"",
|
|
280
|
-
"## Identifiers mentioned",
|
|
281
|
-
"A bullet list of every opaque identifier that appeared, one per line, copied exactly. Write `- none` if there were none.",
|
|
270
|
+
"Put the gist in `summary`, decisions and their reasons in `decisions`, pending work in `openItems`, and every opaque identifier copied exactly in `identifiers`. Keep only the latest decision where one superseded another.",
|
|
282
271
|
"",
|
|
283
272
|
"Leave out pleasantries, repetition, and superseded detail. Do not invent anything that is not in the transcript. Do not address the user.",
|
|
284
273
|
].join("\n");
|
|
285
274
|
|
|
275
|
+
export interface CompactionSummaryPayloadV1 {
|
|
276
|
+
summary: string;
|
|
277
|
+
decisions: string[];
|
|
278
|
+
openItems: string[];
|
|
279
|
+
identifiers: string[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The actual production consumer of the shared structured-output seam. */
|
|
283
|
+
export const COMPACTION_RESPONSE_SCHEMA_V1 = {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
summary: { type: "string" },
|
|
287
|
+
decisions: { type: "array", items: { type: "string" } },
|
|
288
|
+
openItems: { type: "array", items: { type: "string" } },
|
|
289
|
+
identifiers: { type: "array", items: { type: "string" } },
|
|
290
|
+
},
|
|
291
|
+
required: ["summary", "decisions", "openItems", "identifiers"],
|
|
292
|
+
additionalProperties: false,
|
|
293
|
+
} as const satisfies StructuredOutputSchemaV1;
|
|
294
|
+
|
|
295
|
+
/** Keeps the durable summary format readable while model I/O stays typed. */
|
|
296
|
+
export function renderCompactionSummaryV1(
|
|
297
|
+
payload: CompactionSummaryPayloadV1,
|
|
298
|
+
): string {
|
|
299
|
+
const bullets = (values: readonly string[]) =>
|
|
300
|
+
values.length > 0
|
|
301
|
+
? values.map((value) => `- ${value}`).join("\n")
|
|
302
|
+
: "- none";
|
|
303
|
+
return [
|
|
304
|
+
"## Summary",
|
|
305
|
+
payload.summary,
|
|
306
|
+
"",
|
|
307
|
+
"## Decisions",
|
|
308
|
+
bullets(payload.decisions),
|
|
309
|
+
"",
|
|
310
|
+
"## Open items",
|
|
311
|
+
bullets(payload.openItems),
|
|
312
|
+
"",
|
|
313
|
+
"## Identifiers mentioned",
|
|
314
|
+
bullets(payload.identifiers),
|
|
315
|
+
].join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
286
318
|
/** The transcript one summariser call is given, flattened to plain text. */
|
|
287
319
|
export function compactionTranscriptV1(
|
|
288
320
|
messages: readonly LlmMessage[],
|