@frockbot/kernel-do 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 +3 -3
- package/src/authority.ts +61 -8
- package/src/model-timeout-settlement.test.ts +265 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/kernel-composition": "0.3.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
15
|
+
"@frockbot/kernel-composition": "0.3.14",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.14",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/authority.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
eventsForFailedRun,
|
|
36
36
|
latestModelRequestJournalState,
|
|
37
|
+
latestModelRequestProviderV1,
|
|
37
38
|
planBotRunRecovery,
|
|
38
39
|
type ProviderReconcilesV1,
|
|
39
40
|
repairedSessionLogV1,
|
|
@@ -256,7 +257,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
256
257
|
// Turn is durable either way and the alarm retries it; admission now
|
|
257
258
|
// refuses or supersedes on its own terms.
|
|
258
259
|
await this.recoverActiveRun().catch(() => undefined);
|
|
259
|
-
const replay = await this.
|
|
260
|
+
const replay = await this.settledReplayResult(command);
|
|
260
261
|
if (replay) return replay;
|
|
261
262
|
const admission = await this.acceptRun(command);
|
|
262
263
|
if (admission.kind === "queued") {
|
|
@@ -496,7 +497,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
496
497
|
// followed 500'd on the half-repaired record. The run is durable and
|
|
497
498
|
// terminal by this point, and its own record says why it ended, so the
|
|
498
499
|
// caller is handed that record and reads the reason from the transcript.
|
|
499
|
-
const settled = await this.
|
|
500
|
+
const settled = await this.settledTerminalRunResult(runId);
|
|
500
501
|
if (settled) return settled;
|
|
501
502
|
throw error;
|
|
502
503
|
}
|
|
@@ -507,7 +508,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
507
508
|
* resolving has reached a terminal state — whatever that state turned out to
|
|
508
509
|
* be. Anything still open is not this method's to answer for.
|
|
509
510
|
*/
|
|
510
|
-
private async
|
|
511
|
+
private async settledTerminalRunResult(
|
|
511
512
|
runId: string,
|
|
512
513
|
): Promise<BotTurnCompletion | undefined> {
|
|
513
514
|
const run = this.codec.optional(
|
|
@@ -607,7 +608,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
607
608
|
modelState.status === "unresolved") &&
|
|
608
609
|
!runWasDiscardedV1(durableRun)
|
|
609
610
|
) {
|
|
610
|
-
await this.
|
|
611
|
+
const settled = await this.parkOrSettleUnresolvedRun(
|
|
611
612
|
command.runId,
|
|
612
613
|
previous,
|
|
613
614
|
events,
|
|
@@ -615,10 +616,19 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
615
616
|
? unresolvedModelRequestFailure(events, modelState.request)
|
|
616
617
|
: message,
|
|
617
618
|
);
|
|
619
|
+
if (settled) return settled;
|
|
618
620
|
throw new Error(message);
|
|
619
621
|
}
|
|
620
622
|
await this.failRun(command.runId, previous, events, message);
|
|
621
|
-
|
|
623
|
+
// `settledTerminalRunResult`, not `discardedRunResult`: a Turn the Package failed
|
|
624
|
+
// outright — a provider 401, a step limit — reaches a `turn/end` and a
|
|
625
|
+
// durable `failed` record just as surely as a stopped one does, and
|
|
626
|
+
// rethrowing over the top of that settlement is what made the Worker log
|
|
627
|
+
// `Uncaught Error: Bot turn ended with outcome model-error: Model request
|
|
628
|
+
// failed (401)` and answer 500. The run is terminal by this point and its
|
|
629
|
+
// record says why; the caller is handed that record and the client reads
|
|
630
|
+
// the sentence for the outcome off it.
|
|
631
|
+
const settled = await this.settledTerminalRunResult(command.runId);
|
|
622
632
|
if (settled) return settled;
|
|
623
633
|
throw new Error(message);
|
|
624
634
|
} finally {
|
|
@@ -748,7 +758,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
748
758
|
modelState.status === "unresolved") &&
|
|
749
759
|
!runWasDiscardedV1(durableRun)
|
|
750
760
|
) {
|
|
751
|
-
await this.
|
|
761
|
+
const parked = await this.parkOrSettleUnresolvedRun(
|
|
752
762
|
run.runId,
|
|
753
763
|
previous,
|
|
754
764
|
events,
|
|
@@ -756,10 +766,11 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
756
766
|
? unresolvedModelRequestFailure(events, modelState.request)
|
|
757
767
|
: message,
|
|
758
768
|
);
|
|
769
|
+
if (parked) return parked;
|
|
759
770
|
throw new Error(message);
|
|
760
771
|
}
|
|
761
772
|
await this.failRun(run.runId, previous, events, message);
|
|
762
|
-
const settled = await this.
|
|
773
|
+
const settled = await this.settledTerminalRunResult(run.runId);
|
|
763
774
|
if (settled) return settled;
|
|
764
775
|
throw new Error(message);
|
|
765
776
|
} finally {
|
|
@@ -783,7 +794,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
783
794
|
});
|
|
784
795
|
}
|
|
785
796
|
|
|
786
|
-
private async
|
|
797
|
+
private async settledReplayResult(
|
|
787
798
|
command: OwnedBotTurnCommand,
|
|
788
799
|
): Promise<BotTurnCompletion | undefined> {
|
|
789
800
|
const { runId } = command;
|
|
@@ -1728,6 +1739,48 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1728
1739
|
});
|
|
1729
1740
|
}
|
|
1730
1741
|
|
|
1742
|
+
/**
|
|
1743
|
+
* Settles a Turn whose model outcome is unknown, or parks it when somebody
|
|
1744
|
+
* can still be asked — and never lets the uncertainty escape as a throw.
|
|
1745
|
+
*
|
|
1746
|
+
* This is ADR 0028 applied to the live path. Recovery already refuses to park
|
|
1747
|
+
* a run whose provider offers no retrieval, because parking there is not
|
|
1748
|
+
* caution but a dead end; the executing path did not, and the asymmetry is
|
|
1749
|
+
* what produced the blocker. A model request that ran past its budget threw
|
|
1750
|
+
* out of the Agent as an uncertain outcome, this method's predecessor parked
|
|
1751
|
+
* the run and rethrew, and the `POST /turns` the composer was holding open
|
|
1752
|
+
* answered 500 — so the person read "Couldn't reach the Bot. Check your
|
|
1753
|
+
* connection", which blamed their network for a model that took too long,
|
|
1754
|
+
* and the Bot stayed wedged behind a banner whose only possible resolution
|
|
1755
|
+
* was the settlement we could have written here.
|
|
1756
|
+
*
|
|
1757
|
+
* When the provider does reconcile, nothing changes: the run parks, the
|
|
1758
|
+
* caller still rethrows, and a later attempt can genuinely retrieve the
|
|
1759
|
+
* effect. Uncertainty is never assumed away in either branch — the request is
|
|
1760
|
+
* not re-sent, and every streamed word stays in the journal.
|
|
1761
|
+
*
|
|
1762
|
+
* Returns the settled completion when it settled, `undefined` when it parked.
|
|
1763
|
+
*/
|
|
1764
|
+
private async parkOrSettleUnresolvedRun(
|
|
1765
|
+
runId: string,
|
|
1766
|
+
previous: SessionEvent[],
|
|
1767
|
+
events: SessionEvent[],
|
|
1768
|
+
reason: string,
|
|
1769
|
+
): Promise<BotTurnCompletion | undefined> {
|
|
1770
|
+
const provider = latestModelRequestProviderV1(events);
|
|
1771
|
+
const reconciles = this.hooks.providerReconciles ?? (() => true);
|
|
1772
|
+
if (provider === undefined || reconciles(provider)) {
|
|
1773
|
+
await this.requireRunReconciliation(runId, previous, events, reason);
|
|
1774
|
+
return undefined;
|
|
1775
|
+
}
|
|
1776
|
+
// `failRun` runs the ordinary terminal settlement: the open Turn is closed
|
|
1777
|
+
// with a `turn/end`, the partial text is kept, and the record carries the
|
|
1778
|
+
// reason. The reason is a diagnostic for the debug surface — what the
|
|
1779
|
+
// person reads is the client's own copy for the outcome.
|
|
1780
|
+
await this.failRun(runId, previous, events, reason);
|
|
1781
|
+
return this.settledTerminalRunResult(runId);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1731
1784
|
/**
|
|
1732
1785
|
* Starts the Turn that was waiting when the object last stopped.
|
|
1733
1786
|
*
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// A model request that ran out of time settles the Turn instead of escaping.
|
|
2
|
+
//
|
|
3
|
+
// The blocker this file describes: a tool-heavy step against a real Flock AI
|
|
4
|
+
// model crossed the gateway's sixty-second budget, the abort surfaced out of
|
|
5
|
+
// the Agent as `Model response outcome is uncertain: The operation was aborted
|
|
6
|
+
// due to timeout`, the authority parked the run on a reconciliation and
|
|
7
|
+
// rethrew — and the `POST /api/bots/<bot>/turns` the composer was holding open
|
|
8
|
+
// answered 500 after 65 seconds. On screen: "Couldn't reach the Bot. Check your
|
|
9
|
+
// connection and try again", blaming a network that was fine, over a Bot that
|
|
10
|
+
// stayed wedged behind a banner nothing could ever resolve.
|
|
11
|
+
//
|
|
12
|
+
// ADR 0028 already settled the question for recovery: park only when somebody
|
|
13
|
+
// can be asked, and Flock AI keeps no addressable copy of a completion, so
|
|
14
|
+
// nobody can. This is that rule on the live path.
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
bootstrapGeneration,
|
|
18
|
+
type CompositionGenerationV1,
|
|
19
|
+
} from "@frockbot/kernel-composition/generation";
|
|
20
|
+
import {
|
|
21
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
22
|
+
type SessionEvent,
|
|
23
|
+
} from "@frockbot/kernel-contracts";
|
|
24
|
+
import {
|
|
25
|
+
BotDurableAuthority,
|
|
26
|
+
type BotDurableAuthorityHooks,
|
|
27
|
+
type OwnedBotTurnCommand,
|
|
28
|
+
} from "./authority.ts";
|
|
29
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
30
|
+
import {
|
|
31
|
+
BotTurnExecutionError,
|
|
32
|
+
BotTurnReconciliationRequiredError,
|
|
33
|
+
} from "./turn-errors.ts";
|
|
34
|
+
import { createStoredRunCodecV1, type StoredRunV1 } from "./run-records.ts";
|
|
35
|
+
|
|
36
|
+
const codec = createStoredRunCodecV1<undefined>({
|
|
37
|
+
decodeRunId: (value) => value as string,
|
|
38
|
+
decodeConfigurationSnapshot: () => undefined,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const identity = { userId: "user-1", botId: "primary" };
|
|
42
|
+
const SESSION_ID = "user-1:primary";
|
|
43
|
+
|
|
44
|
+
/** Providers that keep an addressable copy of a completion. Neither does. */
|
|
45
|
+
const RECONCILING_PROVIDERS = new Set(["foundation"]);
|
|
46
|
+
|
|
47
|
+
function bootstrap(): Promise<CompositionGenerationV1> {
|
|
48
|
+
return bootstrapGeneration(
|
|
49
|
+
[
|
|
50
|
+
{
|
|
51
|
+
packageId: "shell",
|
|
52
|
+
specifier: "@frockbot/plugin-shell",
|
|
53
|
+
version: "0.0.1",
|
|
54
|
+
manifest: { id: "shell", version: "0.0.1" },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
{ createdAt: "2026-09-03T00:00:00.000Z" },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function command(runId: string, text: string): OwnedBotTurnCommand {
|
|
62
|
+
return {
|
|
63
|
+
...identity,
|
|
64
|
+
runId,
|
|
65
|
+
sessionId: SESSION_ID,
|
|
66
|
+
acceptedAt: "2026-09-03T00:00:01.000Z",
|
|
67
|
+
text,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An authority whose Package stalls its model request past the budget: it
|
|
73
|
+
* journals the request, streams a first line, then unwinds exactly as the
|
|
74
|
+
* Agent loop does on a deadline — a `model/reconciliation-required` carrying
|
|
75
|
+
* the deadline's own sentence, and no `turn/end`.
|
|
76
|
+
*/
|
|
77
|
+
function createAuthority(
|
|
78
|
+
storage: MemoryStorage,
|
|
79
|
+
provider: string,
|
|
80
|
+
options: { providerReconciles?: boolean; refuseWith?: string } = {},
|
|
81
|
+
): BotDurableAuthority<undefined> {
|
|
82
|
+
const hooks: BotDurableAuthorityHooks<undefined> = {
|
|
83
|
+
resolveAdmissionSnapshot: () => Promise.resolve(undefined),
|
|
84
|
+
bootstrapComposition: () => bootstrap(),
|
|
85
|
+
admittedSnapshot: () => Promise.resolve(undefined),
|
|
86
|
+
...(options.providerReconciles === undefined
|
|
87
|
+
? { providerReconciles: (id: string) => RECONCILING_PROVIDERS.has(id) }
|
|
88
|
+
: { providerReconciles: () => options.providerReconciles as boolean }),
|
|
89
|
+
executeTurn: async (input) => {
|
|
90
|
+
let seq = input.previousEvents.length;
|
|
91
|
+
const appended: SessionEvent[] = [];
|
|
92
|
+
const persist = async (
|
|
93
|
+
...events: Omit<SessionEvent, "seq" | "timestamp">[]
|
|
94
|
+
) => {
|
|
95
|
+
const stamped = events.map(
|
|
96
|
+
(event) =>
|
|
97
|
+
({
|
|
98
|
+
...event,
|
|
99
|
+
seq: seq++,
|
|
100
|
+
timestamp: "2026-09-03T00:00:10.000Z",
|
|
101
|
+
}) as SessionEvent,
|
|
102
|
+
);
|
|
103
|
+
appended.push(...stamped);
|
|
104
|
+
await input.persistSessionEvents(input.command.sessionId, stamped);
|
|
105
|
+
};
|
|
106
|
+
await persist(
|
|
107
|
+
{ type: "turn/start", turn: 1 } as never,
|
|
108
|
+
{ type: "step/start", turn: 1, step: 1 } as never,
|
|
109
|
+
{
|
|
110
|
+
type: "user/message",
|
|
111
|
+
turn: 1,
|
|
112
|
+
step: 1,
|
|
113
|
+
messageId: "message-1",
|
|
114
|
+
text: input.command.text,
|
|
115
|
+
} as never,
|
|
116
|
+
{
|
|
117
|
+
type: "model/request",
|
|
118
|
+
turn: 1,
|
|
119
|
+
step: 1,
|
|
120
|
+
request: {
|
|
121
|
+
requestId: "request-1",
|
|
122
|
+
provider,
|
|
123
|
+
model: "@flock/auto",
|
|
124
|
+
system: "system",
|
|
125
|
+
messages: [{ role: "user", content: input.command.text }],
|
|
126
|
+
tools: [],
|
|
127
|
+
},
|
|
128
|
+
} as never,
|
|
129
|
+
{
|
|
130
|
+
type: "assistant/chunk",
|
|
131
|
+
turn: 1,
|
|
132
|
+
step: 1,
|
|
133
|
+
requestId: "request-1",
|
|
134
|
+
text: "On it — building it now.",
|
|
135
|
+
} as never,
|
|
136
|
+
);
|
|
137
|
+
// A provider that refused definitively — a revoked key answering 401 —
|
|
138
|
+
// reaches a real `turn/end`, the way the Agent loop settles a model
|
|
139
|
+
// error it never has to reconcile.
|
|
140
|
+
if (options.refuseWith) {
|
|
141
|
+
await persist(
|
|
142
|
+
{
|
|
143
|
+
type: "model/effect-not-started",
|
|
144
|
+
turn: 1,
|
|
145
|
+
step: 1,
|
|
146
|
+
requestId: "request-1",
|
|
147
|
+
reason: options.refuseWith,
|
|
148
|
+
} as never,
|
|
149
|
+
{
|
|
150
|
+
type: "step/end",
|
|
151
|
+
turn: 1,
|
|
152
|
+
step: 1,
|
|
153
|
+
outcome: "model-error",
|
|
154
|
+
} as never,
|
|
155
|
+
{
|
|
156
|
+
type: "turn/end",
|
|
157
|
+
turn: 1,
|
|
158
|
+
outcome: "model-error",
|
|
159
|
+
reason: options.refuseWith,
|
|
160
|
+
} as never,
|
|
161
|
+
);
|
|
162
|
+
throw new BotTurnExecutionError(
|
|
163
|
+
`Bot turn ended with outcome model-error: ${options.refuseWith}`,
|
|
164
|
+
appended,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const reason = `Model response outcome is uncertain: ${MODEL_FIRST_BYTE_DEADLINE_REASON_V1}`;
|
|
168
|
+
await persist({
|
|
169
|
+
type: "model/reconciliation-required",
|
|
170
|
+
turn: 1,
|
|
171
|
+
step: 1,
|
|
172
|
+
requestId: "request-1",
|
|
173
|
+
reason,
|
|
174
|
+
} as never);
|
|
175
|
+
throw new BotTurnReconciliationRequiredError(reason, appended);
|
|
176
|
+
},
|
|
177
|
+
notification: () => undefined,
|
|
178
|
+
scheduledDeadlines: () => Promise.resolve([]),
|
|
179
|
+
scheduledWorkInFlight: () => false,
|
|
180
|
+
deferScheduledWork: () => Promise.resolve(),
|
|
181
|
+
settleScheduledWork: () => Promise.resolve(),
|
|
182
|
+
interruptTurn: () => {},
|
|
183
|
+
};
|
|
184
|
+
return new BotDurableAuthority<undefined>({
|
|
185
|
+
state: { storage } as unknown as DurableObjectState,
|
|
186
|
+
codec,
|
|
187
|
+
hooks,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function storedRun(
|
|
192
|
+
storage: MemoryStorage,
|
|
193
|
+
runId: string,
|
|
194
|
+
): StoredRunV1<undefined> {
|
|
195
|
+
return codec.require(storage.values.get(`run:${runId}`));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe("a model request that ran out of time", () => {
|
|
199
|
+
test("settles the Turn and answers the caller instead of throwing", async () => {
|
|
200
|
+
const storage = new MemoryStorage();
|
|
201
|
+
const authority = createAuthority(storage, "flock-ai");
|
|
202
|
+
|
|
203
|
+
// The `POST /turns` the composer is holding open. It resolves — this is
|
|
204
|
+
// the whole defect: it used to reject, and the route answered 500.
|
|
205
|
+
const completion = await authority.run(command("run-1", "build me one"));
|
|
206
|
+
|
|
207
|
+
expect(completion.runId).toBe("run-1");
|
|
208
|
+
const run = storedRun(storage, "run-1");
|
|
209
|
+
expect(run.status).toBe("failed");
|
|
210
|
+
// The ordinary run-terminal path: the open Turn is closed rather than left
|
|
211
|
+
// for the next message to trip over.
|
|
212
|
+
const terminal = run.events.findLast((event) => event.type === "turn/end");
|
|
213
|
+
expect(terminal).toMatchObject({ turn: 1, outcome: "interrupted" });
|
|
214
|
+
// The words the person watched arrive are kept.
|
|
215
|
+
expect(run.events.some((event) => event.type === "assistant/chunk")).toBe(
|
|
216
|
+
true,
|
|
217
|
+
);
|
|
218
|
+
// The stored reason is the diagnostic, and it carries the sentence the
|
|
219
|
+
// client's copy layer reads back out of it.
|
|
220
|
+
expect(run.failure).toContain(MODEL_FIRST_BYTE_DEADLINE_REASON_V1);
|
|
221
|
+
// Nothing is left holding the Bot: the next message is admitted.
|
|
222
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("still parks when the provider can actually be asked", async () => {
|
|
226
|
+
const storage = new MemoryStorage();
|
|
227
|
+
const authority = createAuthority(storage, "foundation");
|
|
228
|
+
|
|
229
|
+
// A provider that keeps a durable copy loses nothing by waiting, so the
|
|
230
|
+
// uncertainty is preserved exactly as before and the caller still learns
|
|
231
|
+
// the Turn did not settle.
|
|
232
|
+
await expect(
|
|
233
|
+
authority.run(command("run-1", "build me one")),
|
|
234
|
+
).rejects.toThrow();
|
|
235
|
+
|
|
236
|
+
const run = storedRun(storage, "run-1");
|
|
237
|
+
expect(run.status).toBe("reconciliation-required");
|
|
238
|
+
expect(run.events.some((event) => event.type === "turn/end")).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// The same leak, one layer over: a Turn that reached a real `turn/end` and a
|
|
242
|
+
// durable `failed` record was *still* rethrown, so the Worker logged
|
|
243
|
+
// `Uncaught Error: Bot turn ended with outcome model-error: Model request
|
|
244
|
+
// failed (401)` and the route answered 500 over a settlement that had
|
|
245
|
+
// already happened.
|
|
246
|
+
test("a provider that refused settles and answers, with no uncaught error", async () => {
|
|
247
|
+
const storage = new MemoryStorage();
|
|
248
|
+
const authority = createAuthority(storage, "flock-ai", {
|
|
249
|
+
refuseWith: "Model request failed (401)",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const completion = await authority.run(command("run-1", "hello"));
|
|
253
|
+
|
|
254
|
+
expect(completion.runId).toBe("run-1");
|
|
255
|
+
const run = storedRun(storage, "run-1");
|
|
256
|
+
expect(run.status).toBe("failed");
|
|
257
|
+
expect(
|
|
258
|
+
run.events.findLast((event) => event.type === "turn/end"),
|
|
259
|
+
).toMatchObject({ outcome: "model-error" });
|
|
260
|
+
// The provider's own words stay on the record for the debug surface; the
|
|
261
|
+
// client maps the outcome to a sentence (`runFailureCopyV1`).
|
|
262
|
+
expect(run.failure).toContain("401");
|
|
263
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
});
|