@frockbot/plugin-shell 0.3.8 → 0.3.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.8",
51
- "@frockbot/application-foundation": "0.3.8",
52
- "@frockbot/catalog-core": "0.3.8",
53
- "@frockbot/client-core": "0.3.8",
54
- "@frockbot/client-ui": "0.3.8",
55
- "@frockbot/computer-core": "0.3.8",
56
- "@frockbot/computer-host-protocol": "0.3.8",
57
- "@frockbot/configuration-core": "0.3.8",
58
- "@frockbot/connection-core": "0.3.8",
59
- "@frockbot/kernel-agent-loop": "0.3.8",
60
- "@frockbot/kernel-composition": "0.3.8",
61
- "@frockbot/kernel-contracts": "0.3.8",
62
- "@frockbot/kernel-do": "0.3.8",
63
- "@frockbot/machine-protocol": "0.3.8",
64
- "@frockbot/plugin-applets": "0.3.8",
65
- "@frockbot/plugin-authoring": "0.3.8",
66
- "@frockbot/plugin-bot-template": "0.3.8",
67
- "@frockbot/plugin-computer": "0.3.8",
68
- "@frockbot/plugin-flock": "0.3.8",
69
- "@frockbot/plugin-image": "0.3.8",
70
- "@frockbot/plugin-machine-messages": "0.3.8",
71
- "@frockbot/plugin-mcp": "0.3.8",
72
- "@frockbot/plugin-memory": "0.3.8",
73
- "@frockbot/plugin-package-catalog": "0.3.8",
74
- "@frockbot/plugin-routines": "0.3.8",
75
- "@frockbot/plugin-skills": "0.3.8",
76
- "@frockbot/plugin-subagents": "0.3.8",
77
- "@frockbot/plugin-user-machine": "0.3.8",
78
- "@frockbot/protocol": "0.3.8",
50
+ "@frockbot/agent-runtime": "0.3.9",
51
+ "@frockbot/application-foundation": "0.3.9",
52
+ "@frockbot/catalog-core": "0.3.9",
53
+ "@frockbot/client-core": "0.3.9",
54
+ "@frockbot/client-ui": "0.3.9",
55
+ "@frockbot/computer-core": "0.3.9",
56
+ "@frockbot/computer-host-protocol": "0.3.9",
57
+ "@frockbot/configuration-core": "0.3.9",
58
+ "@frockbot/connection-core": "0.3.9",
59
+ "@frockbot/kernel-agent-loop": "0.3.9",
60
+ "@frockbot/kernel-composition": "0.3.9",
61
+ "@frockbot/kernel-contracts": "0.3.9",
62
+ "@frockbot/kernel-do": "0.3.9",
63
+ "@frockbot/machine-protocol": "0.3.9",
64
+ "@frockbot/plugin-applets": "0.3.9",
65
+ "@frockbot/plugin-authoring": "0.3.9",
66
+ "@frockbot/plugin-bot-template": "0.3.9",
67
+ "@frockbot/plugin-computer": "0.3.9",
68
+ "@frockbot/plugin-flock": "0.3.9",
69
+ "@frockbot/plugin-image": "0.3.9",
70
+ "@frockbot/plugin-machine-messages": "0.3.9",
71
+ "@frockbot/plugin-mcp": "0.3.9",
72
+ "@frockbot/plugin-memory": "0.3.9",
73
+ "@frockbot/plugin-package-catalog": "0.3.9",
74
+ "@frockbot/plugin-routines": "0.3.9",
75
+ "@frockbot/plugin-skills": "0.3.9",
76
+ "@frockbot/plugin-subagents": "0.3.9",
77
+ "@frockbot/plugin-user-machine": "0.3.9",
78
+ "@frockbot/protocol": "0.3.9",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.8",
84
+ "@frockbot/plugin-tools": "0.3.9",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -301,6 +301,10 @@ export function routineInboxEntryViewV1(
301
301
  ...(entry.acknowledgedAt === undefined
302
302
  ? {}
303
303
  : { acknowledgedAt: entry.acknowledgedAt }),
304
+ ...(entry.repeatCount === undefined
305
+ ? {}
306
+ : { repeatCount: entry.repeatCount }),
307
+ ...(entry.failure === undefined ? {} : { failure: entry.failure }),
304
308
  };
305
309
  }
306
310
 
@@ -0,0 +1,222 @@
1
+ // The foreground `Task` wait (memory-v2 F4).
2
+ //
3
+ // A `Task {background:false}` that answered "dispatched" the instant anything
4
+ // went wrong read to the model as a call that had finished with no output. It
5
+ // then spent two more steps on `task_check` and `task_resume`. The wait must
6
+ // hold until the child settles, and give up only when the record is really
7
+ // unreadable.
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
10
+ import type {
11
+ TaskOutcomeV1,
12
+ TaskRecordV1,
13
+ } from "@frockbot/plugin-subagents/records";
14
+ import { taskKeyV1 } from "@frockbot/plugin-subagents/storage-keys";
15
+ import type { ShellBotBackendHost } from "./backend.js";
16
+
17
+ const identity = { userId: "user-1", botId: "primary" };
18
+ const TASK_ID = "task-1";
19
+
20
+ class MemoryStorage {
21
+ readonly values = new Map<string, unknown>();
22
+ /** Reads to fail before the first success, to stand in for contention. */
23
+ failReads = 0;
24
+ reads = 0;
25
+
26
+ get<T>(key: string): Promise<T | undefined> {
27
+ this.reads += 1;
28
+ if (this.failReads > 0) {
29
+ this.failReads -= 1;
30
+ return Promise.reject(new Error("storage is busy"));
31
+ }
32
+ return Promise.resolve(
33
+ structuredClone(this.values.get(key)) as T | undefined,
34
+ );
35
+ }
36
+
37
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
38
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
39
+ else {
40
+ for (const [entry, item] of Object.entries(key)) {
41
+ this.values.set(entry, structuredClone(item));
42
+ }
43
+ }
44
+ return Promise.resolve();
45
+ }
46
+
47
+ delete(key: string): Promise<boolean> {
48
+ return Promise.resolve(this.values.delete(key));
49
+ }
50
+
51
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
52
+ return Promise.resolve(
53
+ new Map(
54
+ [...this.values.entries()].filter(([key]) =>
55
+ key.startsWith(options.prefix ?? ""),
56
+ ) as Array<[string, T]>,
57
+ ),
58
+ );
59
+ }
60
+
61
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
62
+ return callback(this);
63
+ }
64
+
65
+ setAlarm(): Promise<void> {
66
+ return Promise.resolve();
67
+ }
68
+
69
+ deleteAlarm(): Promise<void> {
70
+ return Promise.resolve();
71
+ }
72
+ }
73
+
74
+ const user: UserSettingsViewV1 = {
75
+ schemaVersion: 1,
76
+ revision: 0,
77
+ profile: { name: "User" },
78
+ packages: [],
79
+ connections: [],
80
+ };
81
+
82
+ function host(storage: MemoryStorage): ShellBotBackendHost {
83
+ return {
84
+ state: { storage } as unknown as DurableObjectState,
85
+ env: {
86
+ USER_CONFIGURATIONS: {
87
+ idFromName: () => "user-id",
88
+ get: () => ({ readConfiguration: () => Promise.resolve(user) }),
89
+ },
90
+ } as unknown as ShellBotBackendHost["env"],
91
+ };
92
+ }
93
+
94
+ interface Waiting {
95
+ sleeps: number;
96
+ materializeSettings(
97
+ identity: { userId: string; botId: string },
98
+ profile: { name: string },
99
+ ): Promise<unknown>;
100
+ wait(taskId: string): Promise<TaskOutcomeV1 | undefined>;
101
+ }
102
+
103
+ /**
104
+ * Exposes the wait and removes its sleep, which is the whole point of it.
105
+ *
106
+ * The class is reached through a dynamic import rather than a top-level one:
107
+ * the Shell's backend and the foundation application import each other, and a
108
+ * static import from a test module reads the class while it is still
109
+ * initializing.
110
+ */
111
+ async function waiting(host: ShellBotBackendHost): Promise<Waiting> {
112
+ // The application first: it is the half of the cycle that has to finish
113
+ // initializing before the Shell's class is readable.
114
+ await import("@frockbot/application-foundation/contributions");
115
+ const { ShellBotBackendContribution } = await import("./backend.js");
116
+ return new (class extends ShellBotBackendContribution {
117
+ sleeps = 0;
118
+
119
+ protected override sleep(): Promise<void> {
120
+ this.sleeps += 1;
121
+ return Promise.resolve();
122
+ }
123
+
124
+ wait(taskId: string): Promise<TaskOutcomeV1 | undefined> {
125
+ return this.awaitBlockingTask(identity, taskId, taskId);
126
+ }
127
+ })(host) as unknown as Waiting;
128
+ }
129
+
130
+ function record(overrides: Partial<TaskRecordV1> = {}): TaskRecordV1 {
131
+ return {
132
+ schemaVersion: 1,
133
+ taskId: TASK_ID,
134
+ type: "executor",
135
+ description: "read it",
136
+ promptDigest: "sha256:digest",
137
+ model: {
138
+ binding: {
139
+ packageId: "models",
140
+ capabilityId: "llm",
141
+ connectionId: "connection-1",
142
+ provider: "foundation",
143
+ providerModelId: "foundation-model",
144
+ },
145
+ slug: "foundation-model",
146
+ },
147
+ compositionGenerationId: "generation-1",
148
+ background: false,
149
+ depth: 1,
150
+ status: "running",
151
+ dispatch: {
152
+ runId: "run-1",
153
+ turnId: "turn-1",
154
+ sessionId: "user-1:primary",
155
+ },
156
+ childSessionId: `task:${TASK_ID}`,
157
+ attachments: [],
158
+ createdAt: "2026-09-03T00:00:00.000Z",
159
+ deadlineAt: "2026-09-03T00:30:00.000Z",
160
+ ...overrides,
161
+ };
162
+ }
163
+
164
+ async function fixture(
165
+ stored: TaskRecordV1,
166
+ ): Promise<{ storage: MemoryStorage; contribution: Waiting }> {
167
+ const storage = new MemoryStorage();
168
+ const contribution = await waiting(host(storage));
169
+ await contribution.materializeSettings(identity, { name: "Primary" });
170
+ storage.values.set(taskKeyV1(stored.taskId), structuredClone(stored));
171
+ return { storage, contribution };
172
+ }
173
+
174
+ describe("the foreground Task wait", () => {
175
+ test("answers with the child's settled outcome", async () => {
176
+ const { contribution } = await fixture(
177
+ record({
178
+ status: "completed",
179
+ outcome: {
180
+ status: "completed",
181
+ settledAt: "2026-09-03T00:00:10.000Z",
182
+ summary: "The root has six entries.",
183
+ },
184
+ }),
185
+ );
186
+
187
+ expect(await contribution.wait(TASK_ID)).toMatchObject({
188
+ status: "completed",
189
+ summary: "The root has six entries.",
190
+ });
191
+ });
192
+
193
+ // The regression: one failed read used to end the wait, and the Task tool
194
+ // then told the model the child was still running — for a child that had
195
+ // already settled.
196
+ test("rides out a transient read failure instead of giving up", async () => {
197
+ const { storage, contribution } = await fixture(
198
+ record({
199
+ status: "completed",
200
+ outcome: {
201
+ status: "completed",
202
+ settledAt: "2026-09-03T00:00:10.000Z",
203
+ summary: "The root has six entries.",
204
+ },
205
+ }),
206
+ );
207
+ storage.failReads = 2;
208
+
209
+ expect(await contribution.wait(TASK_ID)).toMatchObject({
210
+ status: "completed",
211
+ summary: "The root has six entries.",
212
+ });
213
+ expect(contribution.sleeps).toBeGreaterThan(0);
214
+ });
215
+
216
+ test("gives up when the record stays unreadable", async () => {
217
+ const { storage, contribution } = await fixture(record());
218
+ storage.failReads = Number.MAX_SAFE_INTEGER;
219
+
220
+ expect(await contribution.wait(TASK_ID)).toBeUndefined();
221
+ });
222
+ });
package/src/backend.ts CHANGED
@@ -214,6 +214,12 @@ import {
214
214
  } from "./backend-routines.js";
215
215
  import { RoutineInboxStore } from "@frockbot/plugin-routines/inbox-store";
216
216
  import {
217
+ ROUTINE_INBOX_LIMIT,
218
+ ROUTINE_INBOX_PREFIX,
219
+ } from "@frockbot/plugin-routines/storage-keys";
220
+ import {
221
+ decodeRoutineInboxEntryV1,
222
+ routineFailureSentenceV1,
217
223
  subagentAttributionV1,
218
224
  ROUTINE_INBOX_TEXT_MAX,
219
225
  ROUTINE_WAKE_TITLE_MAX,
@@ -3105,10 +3111,10 @@ export class ShellBotBackendContribution {
3105
3111
  runId: fire.fireId,
3106
3112
  createdAt: new Date().toISOString(),
3107
3113
  title: `${settings.profile.name} could not run a Routine`,
3108
- body: (outcome.summary ?? "The firing ended without saying why.").slice(
3109
- 0,
3110
- 240,
3111
- ),
3114
+ // The same sentence the inbox entry carries. A notification is the one
3115
+ // surface a person reads without asking for it, so it is the last place
3116
+ // a kernel invariant belongs.
3117
+ body: routineFailureSentenceV1(outcome.summary).slice(0, 240),
3112
3118
  });
3113
3119
  }
3114
3120
  // -------------------------------------------------------------------------
@@ -3348,11 +3354,38 @@ export class ShellBotBackendContribution {
3348
3354
  }
3349
3355
  if (admission.status === "replayed") {
3350
3356
  // The same tool call, reconciled or retried: the task it already
3351
- // dispatched is the answer, never a second child.
3357
+ // dispatched is the answer, never a second child. A foreground call
3358
+ // still waits for it — the caller asked for the result, and returning
3359
+ // "dispatched" the instant a replay is recognised is what made a
3360
+ // `background:false` Task look like it completed with no output.
3361
+ const replayed = admission.record;
3362
+ const settled =
3363
+ replayed.outcome ??
3364
+ (request.background
3365
+ ? undefined
3366
+ : await this.awaitBlockingTask(
3367
+ identity,
3368
+ taskAnchorIdV1(replayed.childSessionId),
3369
+ replayed.taskId,
3370
+ ));
3371
+ if (settled) {
3372
+ return {
3373
+ status: "settled",
3374
+ taskId: replayed.taskId,
3375
+ model: replayed.model.slug,
3376
+ taskStatus: settled.status,
3377
+ ...(settled.summary === undefined
3378
+ ? {}
3379
+ : { summary: settled.summary }),
3380
+ ...(settled.failure === undefined
3381
+ ? {}
3382
+ : { failure: settled.failure }),
3383
+ };
3384
+ }
3352
3385
  return {
3353
3386
  status: "dispatched",
3354
- taskId: admission.record.taskId,
3355
- model: admission.record.model.slug,
3387
+ taskId: replayed.taskId,
3388
+ model: replayed.model.slug,
3356
3389
  };
3357
3390
  }
3358
3391
  const reservation = await this.subagentSlots(identity).reserve({
@@ -3454,20 +3487,27 @@ export class ShellBotBackendContribution {
3454
3487
  * Durable Object already does inside a Turn, and the outbound probe is the
3455
3488
  * same call reconciliation makes.
3456
3489
  */
3457
- private async awaitBlockingTask(
3490
+ protected async awaitBlockingTask(
3458
3491
  identity: BotIdentity,
3459
3492
  anchorTaskId: string,
3460
3493
  taskId: string,
3461
3494
  ): Promise<TaskOutcomeV1 | undefined> {
3462
3495
  const binding = this.subagentBinding;
3463
3496
  const deadline = Date.now() + TASK_BLOCKING_TIMEOUT_MS_V1;
3497
+ // A read that fails once is transient storage contention, not an answer:
3498
+ // abandoning the wait on the first one returned "still running" for a
3499
+ // child that was about to settle, and taught the model to poll. Only a
3500
+ // record that stays unreadable ends the wait early.
3501
+ const readFailureLimit = 3;
3502
+ let readFailures = 0;
3464
3503
  for (;;) {
3465
3504
  try {
3466
3505
  const record = await this.tasks.read(taskId);
3506
+ readFailures = 0;
3467
3507
  if (record.outcome) return record.outcome;
3468
3508
  } catch {
3469
- // A record that cannot be read is not a reason to hold the Turn.
3470
- return undefined;
3509
+ readFailures += 1;
3510
+ if (readFailures >= readFailureLimit) return undefined;
3471
3511
  }
3472
3512
  if (binding) {
3473
3513
  try {
@@ -5260,11 +5300,30 @@ export class ShellBotBackendContribution {
5260
5300
  const index = await this.authority.listRunIndex({
5261
5301
  limit: UNREAD_COUNT_CAP + 1,
5262
5302
  });
5303
+ // Counted straight off the keys rather than through `RoutineInboxStore`:
5304
+ // its `list()` trims the inbox, and the unread fan-out is a read every
5305
+ // sidebar poll makes for every Bot — it must not write, least of all into
5306
+ // an object that is running a Turn. An undecodable row is skipped, because
5307
+ // a badge is never worth failing a read for.
5308
+ const stored = await this.ctx.storage.list<unknown>({
5309
+ prefix: ROUTINE_INBOX_PREFIX,
5310
+ limit: ROUTINE_INBOX_LIMIT,
5311
+ });
5312
+ let failures = 0;
5313
+ for (const value of stored.values()) {
5314
+ try {
5315
+ const entry = decodeRoutineInboxEntryV1(value);
5316
+ if (entry.failure === true && !entry.acknowledged) failures += 1;
5317
+ } catch {
5318
+ continue;
5319
+ }
5320
+ }
5263
5321
  return projectBotUnreadViewV1(
5264
5322
  identity.botId,
5265
5323
  state,
5266
5324
  index.map((entry) => entry.cursor),
5267
5325
  await this.sidebarPreview(storedPreview, index),
5326
+ failures,
5268
5327
  );
5269
5328
  }
5270
5329
 
@@ -1631,6 +1631,79 @@ describe("active durable Turn projection", () => {
1631
1631
  });
1632
1632
  });
1633
1633
 
1634
+ test("a running Turn's partial text fills the bubble it will settle into", () => {
1635
+ const state: Pick<
1636
+ FrockBotWebData,
1637
+ "messages" | "activeRunId" | "activeRun"
1638
+ > = { messages: [] };
1639
+
1640
+ projectDurableRuns(
1641
+ state,
1642
+ [],
1643
+ [
1644
+ {
1645
+ runId: "run-9",
1646
+ input: "Explain",
1647
+ events: [],
1648
+ status: "running",
1649
+ partialText: "Because it",
1650
+ },
1651
+ ],
1652
+ );
1653
+ expect(state.messages[1]).toMatchObject({
1654
+ text: "Because it",
1655
+ status: "streaming",
1656
+ });
1657
+
1658
+ // One bubble: the settled answer replaces the partial one in place.
1659
+ projectDurableRuns(
1660
+ state,
1661
+ [],
1662
+ [
1663
+ {
1664
+ runId: "run-9",
1665
+ input: "Explain",
1666
+ events: [],
1667
+ status: "completed",
1668
+ responseText: "Because it is.",
1669
+ },
1670
+ ],
1671
+ );
1672
+ expect(state.messages).toHaveLength(2);
1673
+ expect(state.messages[1]).toMatchObject({
1674
+ text: "Because it is.",
1675
+ status: "completed",
1676
+ });
1677
+ });
1678
+
1679
+ test("a Turn that has already delivered a send streams nothing beside it", () => {
1680
+ const state: Pick<
1681
+ FrockBotWebData,
1682
+ "messages" | "activeRunId" | "activeRun"
1683
+ > = { messages: [] };
1684
+
1685
+ projectDurableRuns(
1686
+ state,
1687
+ [],
1688
+ [
1689
+ {
1690
+ runId: "run-10",
1691
+ input: "Explain",
1692
+ events: [
1693
+ {
1694
+ type: "send/to-user",
1695
+ payload: { type: "text", text: "Here you go." },
1696
+ },
1697
+ ],
1698
+ status: "running",
1699
+ partialText: "private scratch space",
1700
+ },
1701
+ ],
1702
+ );
1703
+ expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
1704
+ expect(state.messages[1]?.sends).toHaveLength(1);
1705
+ });
1706
+
1634
1707
  test("projects reconciliation-required recovery state", () => {
1635
1708
  const reconciliation: Pick<
1636
1709
  FrockBotWebData,
@@ -283,10 +283,16 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
283
283
  * model's own assistant text is scratch space and the thread does not draw it
284
284
  * (issue 153): drawing both is how a one-word reply arrived twice, once as the
285
285
  * model's text and once as the bubble that was actually delivered.
286
+ *
287
+ * A running Turn has no `responseText` yet — that is written only at
288
+ * settlement — so it draws the words it has written so far. They occupy the
289
+ * same bubble the settled answer will, and the same send gate applies to
290
+ * both: a Turn that has already delivered a bubble streams nothing into a
291
+ * second one.
286
292
  */
287
293
  function visibleAssistantText(run: ClientRun, fallback = ""): string {
288
294
  if (sendsFrom(run.events).length > 0) return "";
289
- return run.responseText ?? fallback;
295
+ return run.responseText ?? run.partialText ?? fallback;
290
296
  }
291
297
 
292
298
  function isTerminalRun(run: ClientRun): boolean {
@@ -1476,6 +1476,87 @@ describe("dispatched subagents in the run projection", () => {
1476
1476
  }
1477
1477
  });
1478
1478
 
1479
+ test("a running Turn projects the words it has written so far", () => {
1480
+ const streamed: SessionEvent[] = [
1481
+ event({
1482
+ type: "assistant/chunk",
1483
+ seq: 0,
1484
+ timestamp,
1485
+ turn: 1,
1486
+ step: 1,
1487
+ requestId: "request-1",
1488
+ text: "Half a",
1489
+ }),
1490
+ event({
1491
+ type: "assistant/chunk",
1492
+ seq: 1,
1493
+ timestamp,
1494
+ turn: 1,
1495
+ step: 1,
1496
+ requestId: "request-1",
1497
+ text: " thought",
1498
+ }),
1499
+ ];
1500
+
1501
+ const projected = projectClientRunV1(storedRun(streamed, "running"));
1502
+ expect(projected.partialText).toBe("Half a thought");
1503
+ expect(projected.outcome).toBeUndefined();
1504
+
1505
+ // And it survives the wire, so the thread draws it while the Turn runs.
1506
+ const decoded = decodeClientRunPageV1(
1507
+ createClientRunListV1([projected], { truncated: false }),
1508
+ ).runs[0];
1509
+ expect(decoded?.partialText).toBe("Half a thought");
1510
+ expect(decoded?.responseText).toBeUndefined();
1511
+ });
1512
+
1513
+ test("a running Turn that has said nothing carries no partial text", () => {
1514
+ expect(projectClientRunV1(storedRun([], "running")).partialText).toBe(
1515
+ undefined,
1516
+ );
1517
+ });
1518
+
1519
+ test("a later request restarts the partial answer", () => {
1520
+ const streamed: SessionEvent[] = [
1521
+ event({
1522
+ type: "assistant/chunk",
1523
+ seq: 0,
1524
+ timestamp,
1525
+ turn: 1,
1526
+ step: 1,
1527
+ requestId: "request-1",
1528
+ text: "scratch",
1529
+ }),
1530
+ event({
1531
+ type: "assistant/chunk",
1532
+ seq: 1,
1533
+ timestamp,
1534
+ turn: 1,
1535
+ step: 2,
1536
+ requestId: "request-2",
1537
+ text: "the answer",
1538
+ }),
1539
+ ];
1540
+ expect(projectClientRunV1(storedRun(streamed, "running")).partialText).toBe(
1541
+ "the answer",
1542
+ );
1543
+ });
1544
+
1545
+ test("a settled Turn carries its answer once, as an outcome", () => {
1546
+ const projected = projectClientRunV1(storedRun([], "completed"));
1547
+ expect(projected.partialText).toBeUndefined();
1548
+ expect(projected.outcome).toMatchObject({ type: "completed" });
1549
+
1550
+ const page = createClientRunListV1([projected], { truncated: false });
1551
+ const tampered = structuredClone(page) as unknown as {
1552
+ runs: Array<Record<string, unknown>>;
1553
+ };
1554
+ tampered.runs[0]!.partialText = "words";
1555
+ expect(() => decodeClientRunPageV1(tampered)).toThrow(
1556
+ "only a running run may carry partial text",
1557
+ );
1558
+ });
1559
+
1479
1560
  test("refuses a chip whose background flag is not a boolean", () => {
1480
1561
  const page = createClientRunListV1(
1481
1562
  [projectClientRunV1(storedRun([dispatched]))],
@@ -220,6 +220,14 @@ export interface ClientRunV1 {
220
220
  * the flag is durable state, so a reload draws the same thing.
221
221
  */
222
222
  queued?: true;
223
+ /**
224
+ * The answer the Bot has written so far, present only while the run is still
225
+ * running and has produced text. The thread draws it in the bubble it is
226
+ * already drawing for the Turn, so a reply appears as it is written instead
227
+ * of arriving whole at settlement. A settled run carries its answer in
228
+ * `outcome` instead, and never both.
229
+ */
230
+ partialText?: string;
223
231
  outcome?: ClientRunOutcomeV1;
224
232
  recovery?: ClientRunRecoveryV1;
225
233
  }
@@ -738,10 +746,13 @@ function visibleEvents(
738
746
  * fact about what the person watched arrive, not a claim that the Turn
739
747
  * succeeded, and the thread keeps it instead of replacing it with a notice.
740
748
  */
741
- function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
749
+ export function assistantTextSoFarV1(
750
+ events: readonly SessionEvent[],
751
+ responseText = "",
752
+ ): string {
742
753
  let requestId: string | undefined;
743
- let text = run.responseText ?? "";
744
- for (const event of run.events) {
754
+ let text = responseText;
755
+ for (const event of events) {
745
756
  if (event.type === "assistant/chunk") {
746
757
  if (event.requestId !== requestId) {
747
758
  requestId = event.requestId;
@@ -753,9 +764,31 @@ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
753
764
  text = event.text;
754
765
  }
755
766
  }
767
+ return text;
768
+ }
769
+
770
+ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
771
+ const text = assistantTextSoFarV1(run.events, run.responseText ?? "");
756
772
  return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
757
773
  }
758
774
 
775
+ /**
776
+ * What a still-running Turn has said so far, read out of the same journal an
777
+ * interrupted one is read from.
778
+ *
779
+ * The kernel appends an `assistant/chunk` per provider text delta and each
780
+ * append lands on the run record, so the words are already durable while the
781
+ * Turn runs; nothing here is a second copy and nothing crosses the channel.
782
+ * Bounded exactly as an outcome is, because a long answer must not be able to
783
+ * grow the run list past its wire budget.
784
+ */
785
+ function partialTextV1(run: StoredRun): { partialText?: string } {
786
+ const text = assistantTextSoFarV1(run.events);
787
+ return text
788
+ ? { partialText: truncateWireString(text, MAX_OUTCOME_BYTES) }
789
+ : {};
790
+ }
791
+
759
792
  function runStatus(run: StoredRun): ClientRunStatusV1 {
760
793
  return requireStoredRunV1(run).status;
761
794
  }
@@ -810,6 +843,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
810
843
  input: truncateWireString(run.input, MAX_INPUT_BYTES),
811
844
  status,
812
845
  events: visibleEvents(run.events, status),
846
+ ...(status === "running" ? partialTextV1(run) : {}),
813
847
  ...(run.stopRequestedAt
814
848
  ? {
815
849
  stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
@@ -1300,6 +1334,7 @@ function decodeRun(value: unknown): ClientRun {
1300
1334
  "events",
1301
1335
  "stopRequestedAt",
1302
1336
  "queued",
1337
+ "partialText",
1303
1338
  "outcome",
1304
1339
  "recovery",
1305
1340
  ],
@@ -1347,6 +1382,16 @@ function decodeRun(value: unknown): ClientRun {
1347
1382
  if (run.queued === true && runStatus !== "running") {
1348
1383
  throw new Error("only a running run may be queued");
1349
1384
  }
1385
+ let partialText: string | undefined;
1386
+ if (run.partialText !== undefined) {
1387
+ // A settled run's answer is its outcome. Carrying both would give the
1388
+ // thread two sources for one bubble, which is the duplication the
1389
+ // one-bubble contract exists to prevent.
1390
+ if (runStatus !== "running") {
1391
+ throw new Error("only a running run may carry partial text");
1392
+ }
1393
+ partialText = wireString(run, "partialText", MAX_OUTCOME_BYTES, "run");
1394
+ }
1350
1395
  return {
1351
1396
  runId,
1352
1397
  admittedAt,
@@ -1355,6 +1400,7 @@ function decodeRun(value: unknown): ClientRun {
1355
1400
  events: decodeEvents(run.events),
1356
1401
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1357
1402
  ...(run.queued === true ? { queued: true as const } : {}),
1403
+ ...(partialText ? { partialText } : {}),
1358
1404
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
1359
1405
  ...(outcome?.type === "failed"
1360
1406
  ? {
@@ -236,6 +236,33 @@ describe("the unread projection", () => {
236
236
  expect(view).toMatchObject({ count: 0, capped: false, unread: false });
237
237
  });
238
238
 
239
+ // A Routine failing every minute left the badge at zero, because an
240
+ // automation Turn never advances the activity cursor.
241
+ test("badges a Bot whose Routine is failing, with nothing else unread", () => {
242
+ const view = projectBotUnreadViewV1(
243
+ "alpha",
244
+ emptyUnreadStateV1(),
245
+ index(3),
246
+ undefined,
247
+ 2,
248
+ );
249
+ expect(view).toMatchObject({ count: 2, unread: true, capped: false });
250
+ });
251
+
252
+ test("adds Routine failures to the unread chat Turns", () => {
253
+ const state: UnreadStateV1 = {
254
+ schemaVersion: 1,
255
+ lastActivityCursor: cursor(3),
256
+ lastActivityAt: "2026-08-31T00:03:00.000Z",
257
+ lastSeenCursor: cursor(1),
258
+ lastViewedAt: "2026-08-31T00:01:00.000Z",
259
+ manuallyUnread: false,
260
+ };
261
+ expect(
262
+ projectBotUnreadViewV1("alpha", state, index(4), undefined, 1),
263
+ ).toMatchObject({ count: 3, unread: true });
264
+ });
265
+
239
266
  test("carries the already-bounded latest message without deriving it", () => {
240
267
  const preview = decodeSidebarMessagePreviewV1({
241
268
  schemaVersion: 1,
package/src/unread.ts CHANGED
@@ -406,9 +406,17 @@ export function projectBotUnreadViewV1(
406
406
  state: UnreadStateV1,
407
407
  cursors: readonly string[],
408
408
  lastMessage?: SidebarMessagePreviewV1,
409
+ /**
410
+ * Unacknowledged Routine failures. An automation Turn deliberately does not
411
+ * advance the activity cursor, so a Routine failing every minute badged
412
+ * nothing at all — the one Bot the User most needed to look at was the one
413
+ * the sidebar stayed quiet about. A failure is the Bot addressing its User,
414
+ * so it counts here even though the firing that produced it does not.
415
+ */
416
+ automationFailures = 0,
409
417
  ): BotUnreadViewV1 {
410
418
  const ceiling = state.lastActivityCursor;
411
- let counted = 0;
419
+ let counted = Math.max(0, automationFailures);
412
420
  if (ceiling !== undefined) {
413
421
  for (const cursor of cursors) {
414
422
  if (cursor > ceiling) continue;