@gajae-code/coding-agent 0.15.4 → 0.15.6
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/CHANGELOG.md +46 -2
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/update-cli.d.ts +27 -5
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +99 -0
- package/dist/types/config/file-lock.d.ts +49 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +11 -5
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -4
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/sdk/broker/broker.d.ts +3 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/cli/session-cli.d.ts +42 -1
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +19 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +19 -5
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +5 -6
- package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -3
- package/src/async/job-manager.ts +671 -39
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +3 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/update-cli.ts +142 -6
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +5 -0
- package/src/config/autorouting-tier-map.ts +33 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +775 -59
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2799 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +56 -14
- package/src/config/model-registry.ts +1028 -123
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +6 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/policy.ts +1 -1
- package/src/coordinator-mcp/projection-scan.ts +571 -0
- package/src/coordinator-mcp/server.ts +19 -24
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +4 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/launch-worktree.ts +44 -7
- package/src/gjc-runtime/ralplan-runtime.ts +14 -1
- package/src/gjc-runtime/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/gjc-runtime/tmux-sessions.ts +145 -48
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +9 -2
- package/src/modes/acp/acp-agent.ts +11 -5
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +23 -6
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/prompt-action-autocomplete.ts +26 -47
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +48 -21
- package/src/sdk/broker/lifecycle.ts +32 -6
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
- package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
- package/src/sdk/bus/prompt-reconciliation.ts +33 -6
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/cli/session-cli.ts +161 -5
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- package/src/sdk/host/host.ts +7 -0
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +1240 -107
- package/src/sdk/host/session-runtime.ts +1029 -115
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +2 -2
- package/src/sdk/protocol/operation-registry.ts +4 -1
- package/src/sdk/router/session-router.ts +297 -111
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +887 -95
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +64 -26
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +29 -10
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +142 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +786 -67
- package/src/tools/index.ts +7 -5
- package/src/tools/path-utils.ts +77 -29
- package/src/tools/tmux-self-injection-guard.ts +359 -0
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
unregisterOwnedRegistration,
|
|
14
14
|
} from "../../session/terminal-abort";
|
|
15
15
|
import { Broker } from "../broker/broker";
|
|
16
|
+
import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
|
|
17
|
+
import { createPromptReconciliation } from "../bus/prompt-reconciliation";
|
|
16
18
|
import { createReconciliationStore } from "../bus/reconciliation-store";
|
|
17
19
|
import { CursorRegistry, QueryHandlers, RevisionStore } from "./query";
|
|
18
20
|
import {
|
|
@@ -146,6 +148,76 @@ async function queryGoalState(
|
|
|
146
148
|
return response.page?.items[0];
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
|
|
152
|
+
const reconciliation = createPromptReconciliation();
|
|
153
|
+
const correlation = { commandId: "native-empty-command", turnId: "native-empty-turn" };
|
|
154
|
+
reconciliation.admit("native-empty-ref");
|
|
155
|
+
reconciliation.noteAccepted(correlation, "native-empty-ref");
|
|
156
|
+
reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "" });
|
|
157
|
+
expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({
|
|
158
|
+
status: "failed",
|
|
159
|
+
error: { code: "prompt_failed" },
|
|
160
|
+
receiptState: "missing",
|
|
161
|
+
});
|
|
162
|
+
reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "late text" });
|
|
163
|
+
expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({ status: "failed" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("broker reconciliation fails closed for an empty prompt and persists the first terminal result", async () => {
|
|
167
|
+
let records: unknown[] = [];
|
|
168
|
+
const store = {
|
|
169
|
+
load: async () => records,
|
|
170
|
+
transact: async (mutator: (current: never[]) => never[]) => {
|
|
171
|
+
records = mutator(records as never);
|
|
172
|
+
},
|
|
173
|
+
} as never;
|
|
174
|
+
const reconciliation = createKindAwareReconciliation({ store });
|
|
175
|
+
const correlation = { commandId: "broker-empty-command", turnId: "broker-empty-turn" };
|
|
176
|
+
await reconciliation.noteAccepted("prompt", correlation, "broker-empty-ref");
|
|
177
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
178
|
+
type: "agent_end",
|
|
179
|
+
content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
|
|
180
|
+
});
|
|
181
|
+
const settled = reconciliation.lookupResult("prompt", { clientRef: "broker-empty-ref" });
|
|
182
|
+
expect(settled).toMatchObject({ status: "failed", error: { code: "prompt_failed" } });
|
|
183
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
184
|
+
type: "agent_end",
|
|
185
|
+
content: { version: 1, type: "text", text: "late text", byteLength: 9, truncated: false },
|
|
186
|
+
});
|
|
187
|
+
const reloaded = createKindAwareReconciliation({ store });
|
|
188
|
+
await reloaded.hydrateFromStore();
|
|
189
|
+
expect(reloaded.lookupResult("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({
|
|
190
|
+
status: "failed",
|
|
191
|
+
error: { code: "prompt_failed" },
|
|
192
|
+
});
|
|
193
|
+
const noOpCorrelation = { commandId: "broker-noop-command", turnId: "broker-noop-turn" };
|
|
194
|
+
await reconciliation.noteAccepted("prompt", noOpCorrelation, "broker-noop-ref");
|
|
195
|
+
await reconciliation.finalizeOutcome(
|
|
196
|
+
"prompt",
|
|
197
|
+
noOpCorrelation,
|
|
198
|
+
{ kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
199
|
+
undefined,
|
|
200
|
+
"",
|
|
201
|
+
);
|
|
202
|
+
expect(reconciliation.lookupResult("prompt", { clientRef: "broker-noop-ref" })).toMatchObject({
|
|
203
|
+
status: "terminal_ok",
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("session-host prompt reconciliation fails closed when the accepted result is empty", async () => {
|
|
208
|
+
const reconciliation = createInvocationReconciliation();
|
|
209
|
+
const correlation = { commandId: "host-empty-command", turnId: "host-empty-turn" };
|
|
210
|
+
await reconciliation.noteAccepted("prompt", correlation, "host-empty-ref");
|
|
211
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
212
|
+
type: "agent_end",
|
|
213
|
+
content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
|
|
214
|
+
});
|
|
215
|
+
expect(reconciliation.lookup("prompt", { clientRef: "host-empty-ref" })).toMatchObject({
|
|
216
|
+
status: "failed",
|
|
217
|
+
error: { code: "prompt_failed" },
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
149
221
|
describe("SDK goal snapshot lifecycle", () => {
|
|
150
222
|
test("recovers an active nonzero-activity goal from the durable session projection", async () => {
|
|
151
223
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-goal-recovery-"));
|
|
@@ -405,7 +477,10 @@ test("durable reload keeps agent_end terminal across a paused successor transiti
|
|
|
405
477
|
const correlation = { commandId: "durable-race-command", turnId: "durable-race-turn" };
|
|
406
478
|
await reconciliation.noteAccepted("prompt", correlation, "durable-race-ref");
|
|
407
479
|
pauseWrites = 2;
|
|
408
|
-
const terminal = reconciliation.noteTransition("prompt", correlation, {
|
|
480
|
+
const terminal = reconciliation.noteTransition("prompt", correlation, {
|
|
481
|
+
type: "agent_end",
|
|
482
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
483
|
+
});
|
|
409
484
|
await writeStarted[0]!.promise;
|
|
410
485
|
// A successor lifecycle event arrives while the terminal write is held. It
|
|
411
486
|
// must observe the staged terminal record and never resurrect in_flight state.
|
|
@@ -455,7 +530,10 @@ test("agent_end is not swallowed when deadline persistence fails", async () => {
|
|
|
455
530
|
message: "deadline",
|
|
456
531
|
});
|
|
457
532
|
await persistStarted.promise;
|
|
458
|
-
const terminal = reconciliation.noteTransition("prompt", correlation, {
|
|
533
|
+
const terminal = reconciliation.noteTransition("prompt", correlation, {
|
|
534
|
+
type: "agent_end",
|
|
535
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
536
|
+
});
|
|
459
537
|
releasePersist.resolve();
|
|
460
538
|
await expect(deadline).rejects.toThrow("held store failed");
|
|
461
539
|
await terminal;
|
|
@@ -523,13 +601,86 @@ test("agent_end upgrades a durable deadline terminal when it races a successful
|
|
|
523
601
|
code: "prompt_deadline_exceeded",
|
|
524
602
|
message: "deadline",
|
|
525
603
|
});
|
|
526
|
-
await reconciliation.noteTransition("prompt", correlation, {
|
|
604
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
605
|
+
type: "agent_end",
|
|
606
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
607
|
+
});
|
|
527
608
|
const reloaded = createInvocationReconciliation({ store });
|
|
528
609
|
await reloaded.hydrate();
|
|
529
610
|
expect(reloaded.lookup("prompt", { clientRef: "upgrade-race-ref" })).toMatchObject({ status: "terminal_ok" });
|
|
530
611
|
});
|
|
531
612
|
|
|
532
|
-
test("
|
|
613
|
+
test("an empty late agent_end cannot upgrade a durable prompt deadline failure", async () => {
|
|
614
|
+
let records: unknown[] = [];
|
|
615
|
+
const store = {
|
|
616
|
+
path: null,
|
|
617
|
+
load: async () => records,
|
|
618
|
+
transact: async (mutator: (current: never[]) => never[]) => {
|
|
619
|
+
records = mutator(records as never);
|
|
620
|
+
},
|
|
621
|
+
} as never;
|
|
622
|
+
const reconciliation = createInvocationReconciliation({ store });
|
|
623
|
+
const correlation = { commandId: "empty-deadline-command", turnId: "empty-deadline-turn" };
|
|
624
|
+
await reconciliation.noteAccepted("prompt", correlation, "empty-deadline-ref");
|
|
625
|
+
await reconciliation.finalizeOutcome("prompt", correlation, {
|
|
626
|
+
kind: "failed",
|
|
627
|
+
code: "prompt_deadline_exceeded",
|
|
628
|
+
message: "deadline",
|
|
629
|
+
});
|
|
630
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
631
|
+
type: "agent_end",
|
|
632
|
+
content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
|
|
633
|
+
});
|
|
634
|
+
expect(reconciliation.lookup("prompt", { clientRef: "empty-deadline-ref" })).toMatchObject({
|
|
635
|
+
status: "failed",
|
|
636
|
+
error: { code: "prompt_deadline_exceeded" },
|
|
637
|
+
});
|
|
638
|
+
const reloaded = createInvocationReconciliation({ store });
|
|
639
|
+
await reloaded.hydrate();
|
|
640
|
+
expect(reloaded.lookup("prompt", { clientRef: "empty-deadline-ref" })).toMatchObject({
|
|
641
|
+
status: "failed",
|
|
642
|
+
error: { code: "prompt_deadline_exceeded" },
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("whitespace and provider failures stay actionable across a late empty agent_end", async () => {
|
|
647
|
+
let records: unknown[] = [];
|
|
648
|
+
const store = {
|
|
649
|
+
path: null,
|
|
650
|
+
load: async () => records,
|
|
651
|
+
transact: async (mutator: (current: never[]) => never[]) => {
|
|
652
|
+
records = mutator(records as never);
|
|
653
|
+
},
|
|
654
|
+
} as never;
|
|
655
|
+
const reconciliation = createInvocationReconciliation({ store });
|
|
656
|
+
const whitespace = { commandId: "whitespace-command", turnId: "whitespace-turn" };
|
|
657
|
+
await reconciliation.noteAccepted("prompt", whitespace, "whitespace-ref");
|
|
658
|
+
await reconciliation.noteTransition("prompt", whitespace, {
|
|
659
|
+
type: "agent_end",
|
|
660
|
+
content: { version: 1, type: "text", text: " \t\n ", byteLength: 4, truncated: false },
|
|
661
|
+
});
|
|
662
|
+
expect(reconciliation.lookup("prompt", { clientRef: "whitespace-ref" })).toMatchObject({
|
|
663
|
+
status: "failed",
|
|
664
|
+
error: { code: "prompt_failed", message: "Prompt submission failed." },
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const providerFailure = { commandId: "provider-command", turnId: "provider-turn" };
|
|
668
|
+
await reconciliation.noteAccepted("prompt", providerFailure, "provider-ref");
|
|
669
|
+
await reconciliation.noteTransition("prompt", providerFailure, {
|
|
670
|
+
type: "agent_failed",
|
|
671
|
+
error: Object.assign(new Error("provider leaked detail"), { code: "provider_unavailable" }),
|
|
672
|
+
});
|
|
673
|
+
await reconciliation.noteTransition("prompt", providerFailure, {
|
|
674
|
+
type: "agent_end",
|
|
675
|
+
content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
|
|
676
|
+
});
|
|
677
|
+
expect(reconciliation.lookup("prompt", { clientRef: "provider-ref" })).toMatchObject({
|
|
678
|
+
status: "failed",
|
|
679
|
+
error: { code: "provider_unavailable", message: "Prompt submission failed." },
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("an empty prompt terminal remains failed after a late real failure", async () => {
|
|
533
684
|
let records: unknown[] = [];
|
|
534
685
|
let writes = 0;
|
|
535
686
|
const store = {
|
|
@@ -550,9 +701,12 @@ test("failed agent_end upgrade persistence retains the deadline record for retry
|
|
|
550
701
|
code: "prompt_deadline_exceeded",
|
|
551
702
|
message: "deadline",
|
|
552
703
|
});
|
|
553
|
-
await expect(
|
|
554
|
-
"
|
|
555
|
-
|
|
704
|
+
await expect(
|
|
705
|
+
reconciliation.noteTransition("prompt", correlation, {
|
|
706
|
+
type: "agent_end",
|
|
707
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
708
|
+
}),
|
|
709
|
+
).rejects.toThrow("upgrade persist failed");
|
|
556
710
|
expect(reconciliation.lookup("prompt", { clientRef: "upgrade-failure-ref" })).toMatchObject({
|
|
557
711
|
status: "failed",
|
|
558
712
|
error: { code: "prompt_deadline_exceeded" },
|
|
@@ -563,17 +717,132 @@ test("failed agent_end upgrade persistence retains the deadline record for retry
|
|
|
563
717
|
status: "failed",
|
|
564
718
|
error: { code: "prompt_deadline_exceeded" },
|
|
565
719
|
});
|
|
566
|
-
await reconciliation.noteTransition("prompt", correlation, {
|
|
720
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
721
|
+
type: "agent_end",
|
|
722
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
723
|
+
});
|
|
567
724
|
const reloaded = createInvocationReconciliation({ store });
|
|
568
725
|
await reloaded.hydrate();
|
|
569
726
|
expect(reloaded.lookup("prompt", { clientRef: "upgrade-failure-ref" })).toMatchObject({ status: "terminal_ok" });
|
|
570
727
|
});
|
|
571
728
|
|
|
729
|
+
test("late cancellation and non-text activity upgrade deadline terminals", async () => {
|
|
730
|
+
const makeStore = () => {
|
|
731
|
+
let records: unknown[] = [];
|
|
732
|
+
return {
|
|
733
|
+
path: null,
|
|
734
|
+
load: async () => records,
|
|
735
|
+
transact: async (mutator: (current: never[]) => never[]) => {
|
|
736
|
+
records = mutator(records as never);
|
|
737
|
+
},
|
|
738
|
+
} as never;
|
|
739
|
+
};
|
|
740
|
+
const cancelled = createInvocationReconciliation({ store: makeStore() });
|
|
741
|
+
const cancelledCorrelation = { commandId: "late-cancel-command", turnId: "late-cancel-turn" };
|
|
742
|
+
await cancelled.noteAccepted("prompt", cancelledCorrelation, "late-cancel-ref");
|
|
743
|
+
await cancelled.finalizeOutcome("prompt", cancelledCorrelation, {
|
|
744
|
+
kind: "failed",
|
|
745
|
+
code: "prompt_deadline_exceeded",
|
|
746
|
+
message: "deadline",
|
|
747
|
+
});
|
|
748
|
+
await cancelled.noteTransition("prompt", cancelledCorrelation, {
|
|
749
|
+
type: "agent_end",
|
|
750
|
+
outcome: { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
751
|
+
});
|
|
752
|
+
expect(cancelled.lookup("prompt", { clientRef: "late-cancel-ref" })).toMatchObject({ status: "terminal_ok" });
|
|
753
|
+
|
|
754
|
+
const activity = createInvocationReconciliation({ store: makeStore() });
|
|
755
|
+
const activityCorrelation = { commandId: "late-activity-command", turnId: "late-activity-turn" };
|
|
756
|
+
await activity.noteAccepted("prompt", activityCorrelation, "late-activity-ref");
|
|
757
|
+
await activity.finalizeOutcome("prompt", activityCorrelation, {
|
|
758
|
+
kind: "failed",
|
|
759
|
+
code: "prompt_deadline_exceeded",
|
|
760
|
+
message: "deadline",
|
|
761
|
+
});
|
|
762
|
+
await activity.noteTransition("prompt", activityCorrelation, { type: "agent_end", hasActivity: true });
|
|
763
|
+
expect(activity.lookup("prompt", { clientRef: "late-activity-ref" })).toMatchObject({ status: "terminal_ok" });
|
|
764
|
+
});
|
|
765
|
+
test("finalization without an explicit outcome follows the noteTransition evidence predicate", async () => {
|
|
766
|
+
const makeLocalStore = () => {
|
|
767
|
+
let records: unknown[] = [];
|
|
768
|
+
return {
|
|
769
|
+
path: null,
|
|
770
|
+
load: async () => records,
|
|
771
|
+
transact: async (mutator: (current: never[]) => never[]) => {
|
|
772
|
+
records = mutator(records as never);
|
|
773
|
+
},
|
|
774
|
+
} as never;
|
|
775
|
+
};
|
|
776
|
+
const contentBearing = createInvocationReconciliation({ store: makeLocalStore() });
|
|
777
|
+
const contentCorrelation = { commandId: "final-content-command", turnId: "final-content-turn" };
|
|
778
|
+
await contentBearing.noteAccepted("prompt", contentCorrelation, "final-content-ref");
|
|
779
|
+
await contentBearing.finalizeOutcome("prompt", contentCorrelation, undefined, undefined, undefined, {
|
|
780
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
781
|
+
});
|
|
782
|
+
expect(contentBearing.lookup("prompt", { clientRef: "final-content-ref" })).toMatchObject({
|
|
783
|
+
status: "terminal_ok",
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const activityBearing = createInvocationReconciliation({ store: makeLocalStore() });
|
|
787
|
+
const activityCorrelation = { commandId: "final-activity-command", turnId: "final-activity-turn" };
|
|
788
|
+
await activityBearing.noteAccepted("prompt", activityCorrelation, "final-activity-ref");
|
|
789
|
+
await activityBearing.finalizeOutcome("prompt", activityCorrelation, undefined, undefined, undefined, {
|
|
790
|
+
hasActivity: true,
|
|
791
|
+
});
|
|
792
|
+
expect(activityBearing.lookup("prompt", { clientRef: "final-activity-ref" })).toMatchObject({
|
|
793
|
+
status: "terminal_ok",
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const stoppedOutcome = createInvocationReconciliation({ store: makeLocalStore() });
|
|
797
|
+
const stoppedCorrelation = { commandId: "final-stopped-command", turnId: "final-stopped-turn" };
|
|
798
|
+
await stoppedOutcome.noteAccepted("prompt", stoppedCorrelation, "final-stopped-ref");
|
|
799
|
+
await stoppedOutcome.finalizeOutcome("prompt", stoppedCorrelation, undefined, undefined, undefined, {
|
|
800
|
+
outcomeKind: "stopped",
|
|
801
|
+
});
|
|
802
|
+
expect(stoppedOutcome.lookup("prompt", { clientRef: "final-stopped-ref" })).toMatchObject({
|
|
803
|
+
status: "terminal_ok",
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
const evidenceFree = createInvocationReconciliation({ store: makeLocalStore() });
|
|
807
|
+
const emptyCorrelation = { commandId: "final-empty-command", turnId: "final-empty-turn" };
|
|
808
|
+
await evidenceFree.noteAccepted("prompt", emptyCorrelation, "final-empty-ref");
|
|
809
|
+
await evidenceFree.finalizeOutcome("prompt", emptyCorrelation);
|
|
810
|
+
expect(evidenceFree.lookup("prompt", { clientRef: "final-empty-ref" })).toMatchObject({
|
|
811
|
+
status: "failed",
|
|
812
|
+
error: { code: "prompt_failed" },
|
|
813
|
+
});
|
|
814
|
+
const legacyArg5 = createInvocationReconciliation({ store: makeLocalStore() });
|
|
815
|
+
const legacyCorrelation = { commandId: "final-legacy-command", turnId: "final-legacy-turn" };
|
|
816
|
+
await legacyArg5.noteAccepted("prompt", legacyCorrelation, "final-legacy-ref");
|
|
817
|
+
await legacyArg5.finalizeOutcome("prompt", legacyCorrelation, undefined, undefined, {
|
|
818
|
+
content: { version: 1, type: "text", text: "legacy", byteLength: 6, truncated: false },
|
|
819
|
+
});
|
|
820
|
+
expect(legacyArg5.lookup("prompt", { clientRef: "final-legacy-ref" })).toMatchObject({
|
|
821
|
+
status: "terminal_ok",
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const explicitFailure = createInvocationReconciliation({ store: makeLocalStore() });
|
|
825
|
+
const failureCorrelation = { commandId: "final-failure-command", turnId: "final-failure-turn" };
|
|
826
|
+
await explicitFailure.noteAccepted("prompt", failureCorrelation, "final-failure-ref");
|
|
827
|
+
await explicitFailure.finalizeOutcome("prompt", failureCorrelation, {
|
|
828
|
+
kind: "failed",
|
|
829
|
+
code: "provider_rejected",
|
|
830
|
+
message: "provider rejected",
|
|
831
|
+
});
|
|
832
|
+
expect(explicitFailure.lookup("prompt", { clientRef: "final-failure-ref" })).toMatchObject({
|
|
833
|
+
status: "failed",
|
|
834
|
+
error: { code: "provider_rejected" },
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
572
838
|
test("a reason attached after a prompt settled is never replaced by a later failure", async () => {
|
|
573
839
|
const reconciliation = createInvocationReconciliation();
|
|
574
840
|
const correlation = { commandId: "late-reason-command", turnId: "late-reason-turn" };
|
|
575
841
|
await reconciliation.noteAccepted("prompt", correlation, "late-reason-ref");
|
|
576
|
-
await reconciliation.noteTransition("prompt", correlation, {
|
|
842
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
843
|
+
type: "agent_end",
|
|
844
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
845
|
+
});
|
|
577
846
|
const claimed = reconciliation.lookup("prompt", { clientRef: "late-reason-ref" }) as Record<string, unknown>;
|
|
578
847
|
expect(claimed).toEqual({
|
|
579
848
|
status: "terminal_ok",
|
|
@@ -649,7 +918,10 @@ test("retains terminal host reconciliation records past the 15-minute window", a
|
|
|
649
918
|
reconciliation.admit("skill", "aged-skill-ref");
|
|
650
919
|
await reconciliation.noteAccepted("prompt", promptCorrelation, "aged-ref");
|
|
651
920
|
await reconciliation.noteAccepted("skill", skillCorrelation, "aged-skill-ref");
|
|
652
|
-
await reconciliation.noteTransition("prompt", promptCorrelation, {
|
|
921
|
+
await reconciliation.noteTransition("prompt", promptCorrelation, {
|
|
922
|
+
type: "agent_end",
|
|
923
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
924
|
+
});
|
|
653
925
|
await reconciliation.noteTransition("skill", skillCorrelation, { type: "agent_end" });
|
|
654
926
|
|
|
655
927
|
clock += 15 * 60_000 + 1;
|
|
@@ -679,7 +951,10 @@ test("capacity eviction still releases the clientRef and reports honest unknown"
|
|
|
679
951
|
const correlation = { commandId: `capacity-command-${index}`, turnId: `capacity-turn-${index}` };
|
|
680
952
|
reconciliation.admit("prompt", `capacity-ref-${index}`);
|
|
681
953
|
await reconciliation.noteAccepted("prompt", correlation, `capacity-ref-${index}`);
|
|
682
|
-
await reconciliation.noteTransition("prompt", correlation, {
|
|
954
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
955
|
+
type: "agent_end",
|
|
956
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
957
|
+
});
|
|
683
958
|
}
|
|
684
959
|
expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
|
|
685
960
|
status: "unknown",
|
|
@@ -742,7 +1017,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
742
1017
|
},
|
|
743
1018
|
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
744
1019
|
await options?.onPreflightAcceptCommit?.();
|
|
745
|
-
await
|
|
1020
|
+
await neverSettlingPromise();
|
|
746
1021
|
},
|
|
747
1022
|
} as unknown as ExtensionAPI;
|
|
748
1023
|
const transport = memoryTransport();
|
|
@@ -994,7 +1269,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
994
1269
|
},
|
|
995
1270
|
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
996
1271
|
await options?.onPreflightAcceptCommit?.();
|
|
997
|
-
await
|
|
1272
|
+
await neverSettlingPromise();
|
|
998
1273
|
},
|
|
999
1274
|
} as unknown as ExtensionAPI;
|
|
1000
1275
|
const transport = memoryTransport();
|
|
@@ -1356,7 +1631,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1356
1631
|
},
|
|
1357
1632
|
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
1358
1633
|
await options?.onPreflightAcceptCommit?.();
|
|
1359
|
-
await
|
|
1634
|
+
await neverSettlingPromise();
|
|
1360
1635
|
},
|
|
1361
1636
|
} as unknown as ExtensionAPI;
|
|
1362
1637
|
const transport = memoryTransport();
|
|
@@ -1710,7 +1985,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1710
1985
|
// Production-faithful: an accepted run stays in-flight for the whole
|
|
1711
1986
|
// test; sendUserMessage resolution (turn completion) never precedes
|
|
1712
1987
|
// agent_start (#4668 success-retirement).
|
|
1713
|
-
await
|
|
1988
|
+
await neverSettlingPromise();
|
|
1714
1989
|
},
|
|
1715
1990
|
} as unknown as ExtensionAPI;
|
|
1716
1991
|
const transport = memoryTransport();
|
|
@@ -1870,10 +2145,10 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1870
2145
|
prompt("conn-b", "stale-b");
|
|
1871
2146
|
await waitResponse("stale-b");
|
|
1872
2147
|
// A later AGENT-INITIATED turn starts: the pending queue is empty, so
|
|
1873
|
-
// the
|
|
2148
|
+
// the predecessor's conn-a owner is cleared and no SDK client owns it.
|
|
1874
2149
|
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1875
|
-
//
|
|
1876
|
-
transport.feed("conn-
|
|
2150
|
+
// A's stale owner must NOT stop the agent-initiated turn.
|
|
2151
|
+
transport.feed("conn-a", {
|
|
1877
2152
|
type: "control_request",
|
|
1878
2153
|
id: "stale-owner-abort",
|
|
1879
2154
|
operation: "turn.abort",
|
|
@@ -1987,6 +2262,40 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1987
2262
|
}
|
|
1988
2263
|
});
|
|
1989
2264
|
|
|
2265
|
+
test("agent-initiated successor activity cannot renew a stale predecessor deadline", async () => {
|
|
2266
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stale-deadline-owner-"));
|
|
2267
|
+
try {
|
|
2268
|
+
const harness = await invocationHarness("stale-deadline-owner", cwd, {
|
|
2269
|
+
settings: {
|
|
2270
|
+
get: (key: string) =>
|
|
2271
|
+
key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
2272
|
+
} as unknown as Settings,
|
|
2273
|
+
sendUserMessage: async (_content, options) => {
|
|
2274
|
+
await options?.onPreflightAcceptCommit?.();
|
|
2275
|
+
await neverSettlingPromise();
|
|
2276
|
+
},
|
|
2277
|
+
});
|
|
2278
|
+
const prompt = await harness.control("turn.prompt", { text: "predecessor" });
|
|
2279
|
+
expect(prompt.ok).toBe(true);
|
|
2280
|
+
const ids = { commandId: prompt.result?.commandId, turnId: prompt.result?.turnId };
|
|
2281
|
+
await harness.emit("agent_start");
|
|
2282
|
+
// Empty drain represents an agent-initiated successor with no SDK owner.
|
|
2283
|
+
await harness.emit("agent_start");
|
|
2284
|
+
for (let i = 0; i < 3; i += 1) {
|
|
2285
|
+
await harness.emit("tool_execution_start");
|
|
2286
|
+
await Bun.sleep(15);
|
|
2287
|
+
}
|
|
2288
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
2289
|
+
status: "failed",
|
|
2290
|
+
error: { code: "prompt_deadline_exceeded" },
|
|
2291
|
+
});
|
|
2292
|
+
await harness.stop();
|
|
2293
|
+
} finally {
|
|
2294
|
+
await Bun.sleep(50);
|
|
2295
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
|
|
1990
2299
|
test("SDK-only host retires an accepted-then-failed submission so a later agent-initiated turn is not mis-owned", async () => {
|
|
1991
2300
|
// An idle submission is accepted (pending entry pushed) and then REJECTS
|
|
1992
2301
|
// before agent_start (e.g. the busy retry expires). The failed entry must
|
|
@@ -2959,6 +3268,8 @@ interface PreflightHooks {
|
|
|
2959
3268
|
interface ResponseFrame {
|
|
2960
3269
|
id?: string;
|
|
2961
3270
|
ok?: boolean;
|
|
3271
|
+
code?: string;
|
|
3272
|
+
error?: { code: string; message: string };
|
|
2962
3273
|
result?: { status?: string; commandId?: string; turnId?: string; error?: { code: string; message: string } };
|
|
2963
3274
|
}
|
|
2964
3275
|
|
|
@@ -2966,6 +3277,11 @@ interface InvocationHarness {
|
|
|
2966
3277
|
control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
2967
3278
|
query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
2968
3279
|
emit(event: string, payload?: unknown): Promise<void>;
|
|
3280
|
+
switchSession(sessionId: string): Promise<void>;
|
|
3281
|
+
branch(sessionId: string): Promise<void>;
|
|
3282
|
+
requestOnSession(sessionId: string, frame: Record<string, unknown>): Promise<ResponseFrame>;
|
|
3283
|
+
sendToSession(sessionId: string, frame: Record<string, unknown>): void;
|
|
3284
|
+
sent(sessionId: string): SdkFrame[];
|
|
2969
3285
|
readonly broadcasts: SdkFrame[];
|
|
2970
3286
|
stop(): Promise<void>;
|
|
2971
3287
|
}
|
|
@@ -2978,13 +3294,17 @@ async function invocationHarness(
|
|
|
2978
3294
|
sessionId: string,
|
|
2979
3295
|
cwd: string,
|
|
2980
3296
|
hooks: {
|
|
2981
|
-
sendUserMessage?: (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => Promise<
|
|
3297
|
+
sendUserMessage?: (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => Promise<unknown>;
|
|
2982
3298
|
invokeSkill?: (name: string, args?: string, options?: PreflightHooks) => Promise<unknown>;
|
|
2983
3299
|
abort?: () => void;
|
|
2984
3300
|
isIdle?: () => boolean;
|
|
2985
3301
|
settings?: Settings;
|
|
2986
3302
|
/** Throw to inject a durable-write failure for matching transitions. */
|
|
2987
3303
|
persistInterceptor?: (transition: { type: string }) => void;
|
|
3304
|
+
/** Hold one matching durable transition until the test releases it. */
|
|
3305
|
+
persistHold?: { type: string; onEntered: () => void; release: Promise<void> };
|
|
3306
|
+
onLifecycleDrainTimeout?: () => void;
|
|
3307
|
+
onFailureDiagnosticKeyCount?: (count: number) => void;
|
|
2988
3308
|
agentFailedWriteFailures?: number;
|
|
2989
3309
|
},
|
|
2990
3310
|
): Promise<InvocationHarness> {
|
|
@@ -2992,18 +3312,28 @@ async function invocationHarness(
|
|
|
2992
3312
|
const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
|
|
2993
3313
|
const broadcasts: SdkFrame[] = [];
|
|
2994
3314
|
let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
|
|
3315
|
+
const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
|
|
3316
|
+
const sentFrames = new Map<string, SdkFrame[]>();
|
|
2995
3317
|
let nextId = 0;
|
|
2996
3318
|
const api = {
|
|
2997
3319
|
on(event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) {
|
|
2998
3320
|
handlers.set(event, handler);
|
|
2999
3321
|
},
|
|
3000
|
-
sendUserMessage:
|
|
3322
|
+
sendUserMessage: async (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => {
|
|
3323
|
+
const result = await hooks.sendUserMessage?.(content, options);
|
|
3324
|
+
return result === undefined ? "completed" : result;
|
|
3325
|
+
},
|
|
3001
3326
|
} as unknown as ExtensionAPI;
|
|
3002
3327
|
const interceptorStore = hooks.persistInterceptor
|
|
3003
|
-
? createInterceptorReconciliationStore(
|
|
3328
|
+
? createInterceptorReconciliationStore(
|
|
3329
|
+
hooks.persistInterceptor,
|
|
3330
|
+
hooks.agentFailedWriteFailures,
|
|
3331
|
+
hooks.persistHold,
|
|
3332
|
+
)
|
|
3004
3333
|
: undefined;
|
|
3005
3334
|
createSdkSessionRuntimeExtension(api, {
|
|
3006
3335
|
agentDir: cwd,
|
|
3336
|
+
...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
|
|
3007
3337
|
...(interceptorStore
|
|
3008
3338
|
? {
|
|
3009
3339
|
terminalAbortSeams: {
|
|
@@ -3015,6 +3345,9 @@ async function invocationHarness(
|
|
|
3015
3345
|
},
|
|
3016
3346
|
}
|
|
3017
3347
|
: {}),
|
|
3348
|
+
...(hooks.onFailureDiagnosticKeyCount
|
|
3349
|
+
? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
|
|
3350
|
+
: {}),
|
|
3018
3351
|
...(hooks.settings ? { settings: hooks.settings } : {}),
|
|
3019
3352
|
createTransport: async ({ sessionId: id, stateRoot, token }) => ({
|
|
3020
3353
|
sessionId: id,
|
|
@@ -3022,12 +3355,17 @@ async function invocationHarness(
|
|
|
3022
3355
|
token,
|
|
3023
3356
|
onFrame(handler) {
|
|
3024
3357
|
deliver = handler;
|
|
3358
|
+
deliveries.set(id, handler);
|
|
3025
3359
|
return () => {
|
|
3026
3360
|
if (deliver === handler) deliver = undefined;
|
|
3361
|
+
if (deliveries.get(id) === handler) deliveries.delete(id);
|
|
3027
3362
|
};
|
|
3028
3363
|
},
|
|
3029
3364
|
sendFrame(_connectionId, frame) {
|
|
3030
3365
|
const response = frame as ResponseFrame;
|
|
3366
|
+
const frames = sentFrames.get(id) ?? [];
|
|
3367
|
+
frames.push(frame);
|
|
3368
|
+
sentFrames.set(id, frames);
|
|
3031
3369
|
if (typeof response.id === "string") waiters.get(response.id)?.(response);
|
|
3032
3370
|
},
|
|
3033
3371
|
broadcastFrame(frame) {
|
|
@@ -3046,6 +3384,7 @@ async function invocationHarness(
|
|
|
3046
3384
|
...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
|
|
3047
3385
|
sessionManager: {
|
|
3048
3386
|
getSessionId: () => sessionId,
|
|
3387
|
+
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3049
3388
|
getSessionName: () => undefined,
|
|
3050
3389
|
getBranch: () => [],
|
|
3051
3390
|
},
|
|
@@ -3069,6 +3408,44 @@ async function invocationHarness(
|
|
|
3069
3408
|
stop: async () => {
|
|
3070
3409
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3071
3410
|
},
|
|
3411
|
+
switchSession: async sessionId => {
|
|
3412
|
+
await handlers.get("session_switch")?.(
|
|
3413
|
+
{},
|
|
3414
|
+
{
|
|
3415
|
+
...ctx,
|
|
3416
|
+
sessionManager: {
|
|
3417
|
+
...ctx.sessionManager,
|
|
3418
|
+
getSessionId: () => sessionId,
|
|
3419
|
+
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3420
|
+
},
|
|
3421
|
+
},
|
|
3422
|
+
);
|
|
3423
|
+
},
|
|
3424
|
+
branch: async sessionId => {
|
|
3425
|
+
await handlers.get("session_branch")?.(
|
|
3426
|
+
{},
|
|
3427
|
+
{
|
|
3428
|
+
...ctx,
|
|
3429
|
+
sessionManager: {
|
|
3430
|
+
...ctx.sessionManager,
|
|
3431
|
+
getSessionId: () => sessionId,
|
|
3432
|
+
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3433
|
+
},
|
|
3434
|
+
},
|
|
3435
|
+
);
|
|
3436
|
+
},
|
|
3437
|
+
requestOnSession: (sessionId, frame) => {
|
|
3438
|
+
const id = `frame-${nextId}`;
|
|
3439
|
+
nextId += 1;
|
|
3440
|
+
const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
|
|
3441
|
+
waiters.set(id, resolve);
|
|
3442
|
+
deliveries.get(sessionId)?.("client", { ...frame, id } as SdkFrame);
|
|
3443
|
+
return promise;
|
|
3444
|
+
},
|
|
3445
|
+
sendToSession: (sessionId, frame) => {
|
|
3446
|
+
deliveries.get(sessionId)?.("client", frame as SdkFrame);
|
|
3447
|
+
},
|
|
3448
|
+
sent: sessionId => sentFrames.get(sessionId) ?? [],
|
|
3072
3449
|
};
|
|
3073
3450
|
}
|
|
3074
3451
|
|
|
@@ -3078,34 +3455,46 @@ async function invocationHarness(
|
|
|
3078
3455
|
function createInterceptorReconciliationStore(
|
|
3079
3456
|
interceptor: (transition: { type: string }) => void,
|
|
3080
3457
|
agentFailedWriteFailures = 1,
|
|
3458
|
+
persistHold?: { type: string; onEntered: () => void; release: Promise<void> },
|
|
3081
3459
|
): SdkOnlyReconciliationStore {
|
|
3082
3460
|
const failureCounts = new Map<string, number>();
|
|
3461
|
+
let holdConsumed = false;
|
|
3083
3462
|
const backing = createInterceptorBackingStore();
|
|
3084
3463
|
return {
|
|
3085
3464
|
path: null,
|
|
3086
3465
|
load: async () => backing.records.map(record => ({ ...record })),
|
|
3087
3466
|
async transact(mutator) {
|
|
3088
3467
|
const records = mutator(backing.records.map(record => ({ ...record })));
|
|
3468
|
+
let transitionType: "agent_failed" | "agent_end" | undefined;
|
|
3089
3469
|
for (const record of records as Array<{ status?: string; terminalAt?: number; commandId?: string }>) {
|
|
3090
3470
|
// The intermediate durable state an agent_failed write produces:
|
|
3091
3471
|
// the reason is set but the row is not terminal yet. Tests choose how
|
|
3092
3472
|
// many consecutive writes fail before the recovery replay succeeds.
|
|
3093
3473
|
const identity = String(record?.commandId ?? "unknown");
|
|
3094
3474
|
const failureCount = failureCounts.get(identity) ?? 0;
|
|
3475
|
+
if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
|
|
3476
|
+
transitionType ??= "agent_failed";
|
|
3095
3477
|
if (
|
|
3096
3478
|
record?.terminalAt === undefined &&
|
|
3097
3479
|
(record as { error?: unknown }).error !== undefined &&
|
|
3098
3480
|
failureCount < agentFailedWriteFailures
|
|
3099
3481
|
) {
|
|
3482
|
+
transitionType = "agent_failed";
|
|
3100
3483
|
failureCounts.set(identity, failureCount + 1);
|
|
3101
3484
|
interceptor({ type: "agent_failed" });
|
|
3102
3485
|
throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
|
|
3103
3486
|
}
|
|
3104
3487
|
const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
|
|
3105
|
-
if (record
|
|
3488
|
+
if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
|
|
3489
|
+
transitionType = "agent_end";
|
|
3106
3490
|
interceptor({ type: "agent_end" });
|
|
3107
3491
|
}
|
|
3108
3492
|
}
|
|
3493
|
+
if (persistHold && !holdConsumed && persistHold.type === transitionType) {
|
|
3494
|
+
holdConsumed = true;
|
|
3495
|
+
persistHold.onEntered();
|
|
3496
|
+
await persistHold.release;
|
|
3497
|
+
}
|
|
3109
3498
|
backing.records = records;
|
|
3110
3499
|
},
|
|
3111
3500
|
snapshotTerminalScopes: () => [],
|
|
@@ -3125,65 +3514,109 @@ async function settledStatus(
|
|
|
3125
3514
|
name: string,
|
|
3126
3515
|
input: Record<string, unknown>,
|
|
3127
3516
|
): Promise<NonNullable<ResponseFrame["result"]>> {
|
|
3128
|
-
|
|
3517
|
+
// Wall-clock budget instead of a fixed poll count: CI runners can starve the
|
|
3518
|
+
// event loop long enough to exhaust 200 ~1ms polls before a 25ms deadline
|
|
3519
|
+
// timer is dispatched, failing a correct implementation on timing alone.
|
|
3520
|
+
// The contract is unchanged — the status must still reach a terminal
|
|
3521
|
+
// reconciliation state with the asserted shape within a bounded horizon.
|
|
3522
|
+
const budgetEndsAt = Date.now() + 10_000;
|
|
3523
|
+
for (;;) {
|
|
3129
3524
|
const frame = await harness.query(name, input);
|
|
3130
3525
|
const result = frame.result;
|
|
3131
3526
|
if (result && (result.status === "failed" || result.status === "terminal_ok")) return result;
|
|
3527
|
+
if (Date.now() > budgetEndsAt) throw new Error(`${name} never reported a terminal reconciliation status`);
|
|
3132
3528
|
await Bun.sleep(1);
|
|
3133
3529
|
}
|
|
3134
|
-
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
function neverSettlingPromise(): Promise<void> {
|
|
3533
|
+
return Promise.withResolvers<void>().promise;
|
|
3135
3534
|
}
|
|
3136
3535
|
|
|
3137
3536
|
describe("post-acceptance invocation terminalization", () => {
|
|
3138
|
-
test(
|
|
3139
|
-
|
|
3537
|
+
test.each([
|
|
3538
|
+
{ status: 402, code: "provider_http_402" },
|
|
3539
|
+
{ status: 429, code: "provider_http_429" },
|
|
3540
|
+
{ status: 500, code: "provider_rejected" },
|
|
3541
|
+
])("terminalizes a streamed provider HTTP $status rejection and permits a healthy abort_and_prompt replacement", async ({
|
|
3542
|
+
status,
|
|
3543
|
+
code,
|
|
3544
|
+
}) => {
|
|
3545
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-provider-http-${status}-`));
|
|
3140
3546
|
try {
|
|
3141
|
-
|
|
3547
|
+
let prompts = 0;
|
|
3548
|
+
const harness = await invocationHarness(`provider-http-${status}`, cwd, {
|
|
3142
3549
|
sendUserMessage: async (_content, options) => {
|
|
3550
|
+
prompts += 1;
|
|
3143
3551
|
await options?.onPreflightAcceptCommit?.();
|
|
3144
|
-
|
|
3552
|
+
if (prompts === 1) await neverSettlingPromise();
|
|
3145
3553
|
},
|
|
3146
3554
|
});
|
|
3147
|
-
const
|
|
3148
|
-
expect(
|
|
3149
|
-
const { commandId, turnId
|
|
3555
|
+
const failed = await harness.control("turn.prompt", { text: "provider rejects", clientRef: `http-${status}` });
|
|
3556
|
+
expect(failed.ok).toBe(true);
|
|
3557
|
+
const failedIds = { commandId: failed.result?.commandId, turnId: failed.result?.turnId };
|
|
3150
3558
|
await harness.emit("agent_start");
|
|
3151
|
-
await harness.emit("
|
|
3152
|
-
|
|
3559
|
+
await harness.emit("agent_end", {
|
|
3560
|
+
messages: [
|
|
3561
|
+
{
|
|
3562
|
+
role: "assistant",
|
|
3563
|
+
stopReason: "error",
|
|
3564
|
+
errorStatus: status,
|
|
3565
|
+
transportFailure: { kind: "transport", status },
|
|
3566
|
+
},
|
|
3567
|
+
],
|
|
3153
3568
|
});
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3569
|
+
|
|
3570
|
+
const terminal = await settledStatus(harness, "turn.result", {
|
|
3571
|
+
kind: "prompt",
|
|
3572
|
+
clientRef: `http-${status}`,
|
|
3573
|
+
});
|
|
3574
|
+
expect(terminal).toMatchObject({
|
|
3158
3575
|
status: "failed",
|
|
3159
|
-
error: { code
|
|
3576
|
+
error: { code, message: "Prompt submission failed." },
|
|
3577
|
+
terminalAt: expect.any(Number),
|
|
3578
|
+
});
|
|
3579
|
+
const stable = await harness.query("turn.result", { kind: "prompt", ...failedIds });
|
|
3580
|
+
expect(stable.result).toEqual(terminal);
|
|
3581
|
+
|
|
3582
|
+
const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
|
|
3583
|
+
expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
|
|
3584
|
+
const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
|
|
3585
|
+
await harness.emit("agent_start");
|
|
3586
|
+
await harness.emit("agent_end", {
|
|
3587
|
+
messages: [
|
|
3588
|
+
{ role: "assistant", stopReason: "error", errorStatus: 429 },
|
|
3589
|
+
{ role: "assistant", stopReason: "stop" },
|
|
3590
|
+
],
|
|
3591
|
+
});
|
|
3592
|
+
expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
|
|
3593
|
+
status: "terminal_ok",
|
|
3160
3594
|
});
|
|
3595
|
+
expect(prompts).toBe(2);
|
|
3161
3596
|
await harness.stop();
|
|
3162
3597
|
} finally {
|
|
3163
|
-
// Let the reconciliation store finish its atomic write before the state root disappears.
|
|
3164
3598
|
await Bun.sleep(50);
|
|
3165
3599
|
await rm(cwd, { recursive: true, force: true });
|
|
3166
3600
|
}
|
|
3167
3601
|
});
|
|
3168
3602
|
|
|
3169
|
-
test("
|
|
3170
|
-
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-
|
|
3603
|
+
test("classifies a statusless provider error completion as rejected", async () => {
|
|
3604
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-"));
|
|
3171
3605
|
try {
|
|
3172
|
-
const harness = await invocationHarness("
|
|
3606
|
+
const harness = await invocationHarness("provider-statusless", cwd, {
|
|
3173
3607
|
sendUserMessage: async (_content, options) => {
|
|
3174
3608
|
await options?.onPreflightAcceptCommit?.();
|
|
3175
|
-
await
|
|
3609
|
+
await neverSettlingPromise();
|
|
3176
3610
|
},
|
|
3177
|
-
abort: () => {},
|
|
3178
3611
|
});
|
|
3179
|
-
const accepted = await harness.control("turn.prompt", { text: "
|
|
3180
|
-
|
|
3612
|
+
const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
|
|
3613
|
+
expect(accepted.ok).toBe(true);
|
|
3614
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3181
3615
|
await harness.emit("agent_start");
|
|
3182
|
-
await harness.emit("
|
|
3183
|
-
await harness.
|
|
3184
|
-
expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
|
|
3616
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3617
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3185
3618
|
status: "failed",
|
|
3186
|
-
error: { code: "
|
|
3619
|
+
error: { code: "provider_rejected" },
|
|
3187
3620
|
});
|
|
3188
3621
|
await harness.stop();
|
|
3189
3622
|
} finally {
|
|
@@ -3191,46 +3624,652 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3191
3624
|
await rm(cwd, { recursive: true, force: true });
|
|
3192
3625
|
}
|
|
3193
3626
|
});
|
|
3194
|
-
|
|
3195
|
-
|
|
3627
|
+
|
|
3628
|
+
test("upgrades a generic agent_failed diagnostic when agent_end proves provider rejection", async () => {
|
|
3629
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-upgrade-"));
|
|
3196
3630
|
try {
|
|
3197
|
-
const
|
|
3198
|
-
const successorStarted = Promise.withResolvers<void>();
|
|
3199
|
-
let prompts = 0;
|
|
3200
|
-
const harness = await invocationHarness("abort-immediate-prompt", cwd, {
|
|
3631
|
+
const harness = await invocationHarness("provider-statusless-upgrade", cwd, {
|
|
3201
3632
|
sendUserMessage: async (_content, options) => {
|
|
3202
|
-
prompts += 1;
|
|
3203
3633
|
await options?.onPreflightAcceptCommit?.();
|
|
3204
|
-
|
|
3205
|
-
await firstInflight.promise;
|
|
3206
|
-
return;
|
|
3207
|
-
}
|
|
3208
|
-
successorStarted.resolve();
|
|
3209
|
-
await Promise.withResolvers<void>().promise;
|
|
3634
|
+
await neverSettlingPromise();
|
|
3210
3635
|
},
|
|
3211
3636
|
});
|
|
3212
|
-
const
|
|
3213
|
-
expect(
|
|
3214
|
-
const
|
|
3215
|
-
await harness.emit("agent_start");
|
|
3216
|
-
expect(await harness.control("turn.abort", {})).toMatchObject({ ok: true });
|
|
3217
|
-
firstInflight.reject(Object.assign(new Error("turn aborted"), { code: "aborted" }));
|
|
3218
|
-
const second = await harness.control("turn.prompt", { text: "successor" });
|
|
3219
|
-
expect(second.ok).toBe(true);
|
|
3220
|
-
const secondIds = { commandId: second.result?.commandId, turnId: second.result?.turnId };
|
|
3221
|
-
expect(secondIds.commandId).not.toBe(firstIds.commandId);
|
|
3222
|
-
expect(secondIds.turnId).not.toBe(firstIds.turnId);
|
|
3637
|
+
const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
|
|
3638
|
+
expect(accepted.ok).toBe(true);
|
|
3639
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3223
3640
|
await harness.emit("agent_start");
|
|
3224
|
-
await
|
|
3225
|
-
|
|
3226
|
-
expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
|
|
3227
|
-
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
3641
|
+
await harness.emit("agent_failed", {
|
|
3642
|
+
error: Object.assign(new Error("generic agent failure"), { code: "agent_failed" }),
|
|
3228
3643
|
});
|
|
3229
|
-
|
|
3644
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3645
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3230
3646
|
status: "failed",
|
|
3231
|
-
error: { code: "
|
|
3647
|
+
error: { code: "provider_rejected" },
|
|
3232
3648
|
});
|
|
3233
|
-
|
|
3649
|
+
const failures = harness.broadcasts.filter(frame => frame.kind === "agent_failed");
|
|
3650
|
+
expect(failures.at(-1)).toMatchObject({ payload: { error: { code: "provider_rejected" } } });
|
|
3651
|
+
await harness.stop();
|
|
3652
|
+
} finally {
|
|
3653
|
+
await Bun.sleep(50);
|
|
3654
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3655
|
+
}
|
|
3656
|
+
});
|
|
3657
|
+
|
|
3658
|
+
test("preserves a specific agent_failed diagnostic when agent_end is statusless", async () => {
|
|
3659
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-specific-"));
|
|
3660
|
+
try {
|
|
3661
|
+
const harness = await invocationHarness("provider-statusless-specific", cwd, {
|
|
3662
|
+
sendUserMessage: async (_content, options) => {
|
|
3663
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3664
|
+
await neverSettlingPromise();
|
|
3665
|
+
},
|
|
3666
|
+
});
|
|
3667
|
+
const accepted = await harness.control("turn.prompt", { text: "provider unavailable" });
|
|
3668
|
+
expect(accepted.ok).toBe(true);
|
|
3669
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3670
|
+
await harness.emit("agent_start");
|
|
3671
|
+
await harness.emit("agent_failed", {
|
|
3672
|
+
error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
|
|
3673
|
+
});
|
|
3674
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3675
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3676
|
+
status: "failed",
|
|
3677
|
+
error: { code: "provider_unavailable" },
|
|
3678
|
+
});
|
|
3679
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(1);
|
|
3680
|
+
await harness.stop();
|
|
3681
|
+
} finally {
|
|
3682
|
+
await Bun.sleep(50);
|
|
3683
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3684
|
+
}
|
|
3685
|
+
});
|
|
3686
|
+
|
|
3687
|
+
test("upgrades every shared generic diagnostic on a statusless provider end", async () => {
|
|
3688
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-shared-"));
|
|
3689
|
+
try {
|
|
3690
|
+
let promoted: ((promotion: { startsOwnRun?: boolean }) => void) | undefined;
|
|
3691
|
+
const harness = await invocationHarness("provider-statusless-shared", cwd, {
|
|
3692
|
+
sendUserMessage: async (content, options) => {
|
|
3693
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3694
|
+
if (content === "attached") {
|
|
3695
|
+
promoted = (options as { onQueuedPromoted?: (promotion: { startsOwnRun?: boolean }) => void })
|
|
3696
|
+
?.onQueuedPromoted;
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
await neverSettlingPromise();
|
|
3700
|
+
},
|
|
3701
|
+
});
|
|
3702
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
3703
|
+
expect(first.ok).toBe(true);
|
|
3704
|
+
await harness.emit("agent_start");
|
|
3705
|
+
const attached = await harness.control("turn.follow_up", { text: "attached" });
|
|
3706
|
+
expect(attached.ok).toBe(true);
|
|
3707
|
+
promoted?.({ startsOwnRun: false });
|
|
3708
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3709
|
+
const attachedIds = { commandId: attached.result?.commandId, turnId: attached.result?.turnId };
|
|
3710
|
+
await harness.emit("agent_failed", { error: Object.assign(new Error("generic"), { code: "agent_failed" }) });
|
|
3711
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3712
|
+
expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
|
|
3713
|
+
status: "failed",
|
|
3714
|
+
error: { code: "provider_rejected" },
|
|
3715
|
+
});
|
|
3716
|
+
expect(await settledStatus(harness, "turn.prompt_status", attachedIds)).toMatchObject({
|
|
3717
|
+
status: "failed",
|
|
3718
|
+
error: { code: "provider_rejected" },
|
|
3719
|
+
});
|
|
3720
|
+
await harness.stop();
|
|
3721
|
+
} finally {
|
|
3722
|
+
await Bun.sleep(50);
|
|
3723
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3724
|
+
}
|
|
3725
|
+
});
|
|
3726
|
+
|
|
3727
|
+
test("does not misclassify a local malformed-tool circuit breaker as provider rejection", async () => {
|
|
3728
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-malformed-tool-"));
|
|
3729
|
+
try {
|
|
3730
|
+
const harness = await invocationHarness("local-malformed-tool", cwd, {
|
|
3731
|
+
sendUserMessage: async (_content, options) => {
|
|
3732
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3733
|
+
await neverSettlingPromise();
|
|
3734
|
+
},
|
|
3735
|
+
});
|
|
3736
|
+
const accepted = await harness.control("turn.prompt", { text: "local malformed tool" });
|
|
3737
|
+
expect(accepted.ok).toBe(true);
|
|
3738
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3739
|
+
await harness.emit("agent_start");
|
|
3740
|
+
await harness.emit("agent_end", {
|
|
3741
|
+
messages: [
|
|
3742
|
+
{
|
|
3743
|
+
role: "assistant",
|
|
3744
|
+
stopReason: "error",
|
|
3745
|
+
errorMessage:
|
|
3746
|
+
"Stopping after 3 consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.",
|
|
3747
|
+
},
|
|
3748
|
+
],
|
|
3749
|
+
});
|
|
3750
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3751
|
+
status: "failed",
|
|
3752
|
+
error: { code: "prompt_failed" },
|
|
3753
|
+
});
|
|
3754
|
+
await harness.stop();
|
|
3755
|
+
} finally {
|
|
3756
|
+
await Bun.sleep(50);
|
|
3757
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3758
|
+
}
|
|
3759
|
+
});
|
|
3760
|
+
|
|
3761
|
+
test("does not misclassify a local composer policy breaker as provider rejection", async () => {
|
|
3762
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-composer-policy-"));
|
|
3763
|
+
try {
|
|
3764
|
+
const harness = await invocationHarness("local-composer-policy", cwd, {
|
|
3765
|
+
sendUserMessage: async (_content, options) => {
|
|
3766
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3767
|
+
await neverSettlingPromise();
|
|
3768
|
+
},
|
|
3769
|
+
});
|
|
3770
|
+
const accepted = await harness.control("turn.prompt", { text: "local composer policy" });
|
|
3771
|
+
expect(accepted.ok).toBe(true);
|
|
3772
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3773
|
+
await harness.emit("agent_start");
|
|
3774
|
+
await harness.emit("agent_end", {
|
|
3775
|
+
messages: [
|
|
3776
|
+
{
|
|
3777
|
+
role: "assistant",
|
|
3778
|
+
stopReason: "error",
|
|
3779
|
+
errorMessage:
|
|
3780
|
+
"Composer bash policy blocked repository file I/O again after its one automatic recovery turn.",
|
|
3781
|
+
},
|
|
3782
|
+
],
|
|
3783
|
+
});
|
|
3784
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3785
|
+
status: "failed",
|
|
3786
|
+
error: { code: "prompt_failed" },
|
|
3787
|
+
});
|
|
3788
|
+
await harness.stop();
|
|
3789
|
+
} finally {
|
|
3790
|
+
await Bun.sleep(50);
|
|
3791
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3792
|
+
}
|
|
3793
|
+
});
|
|
3794
|
+
|
|
3795
|
+
test("terminalizes when provider end metadata exposes a throwing accessor", async () => {
|
|
3796
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-throwing-metadata-"));
|
|
3797
|
+
try {
|
|
3798
|
+
const harness = await invocationHarness("provider-throwing-metadata", cwd, {
|
|
3799
|
+
sendUserMessage: async (_content, options) => {
|
|
3800
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3801
|
+
await neverSettlingPromise();
|
|
3802
|
+
},
|
|
3803
|
+
});
|
|
3804
|
+
const accepted = await harness.control("turn.prompt", { text: "provider metadata" });
|
|
3805
|
+
expect(accepted.ok).toBe(true);
|
|
3806
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3807
|
+
await harness.emit("agent_start");
|
|
3808
|
+
const event = {} as Record<string, unknown>;
|
|
3809
|
+
Object.defineProperty(event, "messages", {
|
|
3810
|
+
get() {
|
|
3811
|
+
throw new Error("provider metadata accessor failed");
|
|
3812
|
+
},
|
|
3813
|
+
});
|
|
3814
|
+
await harness.emit("agent_end", event);
|
|
3815
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3816
|
+
status: "failed",
|
|
3817
|
+
error: { code: "prompt_failed" },
|
|
3818
|
+
});
|
|
3819
|
+
await harness.stop();
|
|
3820
|
+
} finally {
|
|
3821
|
+
await Bun.sleep(50);
|
|
3822
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3823
|
+
}
|
|
3824
|
+
});
|
|
3825
|
+
|
|
3826
|
+
test("binds a delayed predecessor provider failure to its own batch after a replacement starts", async () => {
|
|
3827
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-provider-batch-"));
|
|
3828
|
+
try {
|
|
3829
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3830
|
+
const secondInflight = Promise.withResolvers<void>();
|
|
3831
|
+
let prompts = 0;
|
|
3832
|
+
const harness = await invocationHarness("delayed-provider-batch", cwd, {
|
|
3833
|
+
sendUserMessage: async (_content, options) => {
|
|
3834
|
+
prompts += 1;
|
|
3835
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3836
|
+
if (prompts === 1) await firstInflight.promise;
|
|
3837
|
+
if (prompts === 2) await secondInflight.promise;
|
|
3838
|
+
},
|
|
3839
|
+
persistInterceptor: () => {},
|
|
3840
|
+
});
|
|
3841
|
+
const first = await harness.control("turn.prompt", { text: "first", clientRef: "delayed-first" });
|
|
3842
|
+
expect(first.ok).toBe(true);
|
|
3843
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3844
|
+
await harness.emit("agent_start");
|
|
3845
|
+
const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
|
|
3846
|
+
expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
|
|
3847
|
+
const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
|
|
3848
|
+
await harness.emit("agent_start");
|
|
3849
|
+
expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
|
|
3850
|
+
await harness.emit("agent_end", {
|
|
3851
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
|
|
3852
|
+
});
|
|
3853
|
+
expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
|
|
3854
|
+
status: "failed",
|
|
3855
|
+
error: { code: "provider_http_402" },
|
|
3856
|
+
});
|
|
3857
|
+
expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
|
|
3858
|
+
await harness.emit("agent_end", {
|
|
3859
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
|
|
3860
|
+
});
|
|
3861
|
+
expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
|
|
3862
|
+
status: "terminal_ok",
|
|
3863
|
+
});
|
|
3864
|
+
firstInflight.resolve();
|
|
3865
|
+
secondInflight.resolve();
|
|
3866
|
+
expect(prompts).toBe(2);
|
|
3867
|
+
await harness.stop();
|
|
3868
|
+
} finally {
|
|
3869
|
+
await Bun.sleep(50);
|
|
3870
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3871
|
+
}
|
|
3872
|
+
});
|
|
3873
|
+
|
|
3874
|
+
test.each([
|
|
3875
|
+
{ operation: "switchSession" as const, label: "session switch" },
|
|
3876
|
+
{ operation: "branch" as const, label: "session branch" },
|
|
3877
|
+
])("keeps a held 402/429 predecessor terminal across $label", async ({ operation }) => {
|
|
3878
|
+
for (const { status, code } of [
|
|
3879
|
+
{ status: 402, code: "provider_http_402" },
|
|
3880
|
+
{ status: 429, code: "provider_http_429" },
|
|
3881
|
+
]) {
|
|
3882
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-race-`));
|
|
3883
|
+
try {
|
|
3884
|
+
const entered = Promise.withResolvers<void>();
|
|
3885
|
+
const release = Promise.withResolvers<void>();
|
|
3886
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3887
|
+
const secondInflight = Promise.withResolvers<void>();
|
|
3888
|
+
let timeoutWarnings = 0;
|
|
3889
|
+
const diagnosticKeyCounts: number[] = [];
|
|
3890
|
+
let prompts = 0;
|
|
3891
|
+
const harness = await invocationHarness(`${operation}-provider-race`, cwd, {
|
|
3892
|
+
persistInterceptor: () => {},
|
|
3893
|
+
onLifecycleDrainTimeout: () => {
|
|
3894
|
+
timeoutWarnings += 1;
|
|
3895
|
+
},
|
|
3896
|
+
onFailureDiagnosticKeyCount: count => {
|
|
3897
|
+
diagnosticKeyCounts.push(count);
|
|
3898
|
+
},
|
|
3899
|
+
persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
|
|
3900
|
+
agentFailedWriteFailures: 0,
|
|
3901
|
+
sendUserMessage: async (_content, options) => {
|
|
3902
|
+
prompts += 1;
|
|
3903
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3904
|
+
if (prompts === 1) await firstInflight.promise;
|
|
3905
|
+
if (prompts === 2) await secondInflight.promise;
|
|
3906
|
+
},
|
|
3907
|
+
});
|
|
3908
|
+
const first = await harness.control("turn.prompt", {
|
|
3909
|
+
text: "first",
|
|
3910
|
+
clientRef: `${operation}-${status}-first`,
|
|
3911
|
+
});
|
|
3912
|
+
expect(first.ok).toBe(true);
|
|
3913
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3914
|
+
await harness.emit("agent_start");
|
|
3915
|
+
const end = harness.emit("agent_end", {
|
|
3916
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
|
|
3917
|
+
});
|
|
3918
|
+
await entered.promise;
|
|
3919
|
+
const lifecycleChange = harness[operation](`${operation}-successor`);
|
|
3920
|
+
await Bun.sleep(10);
|
|
3921
|
+
const [oldControl, oldQuery, oldReplay, oldProvider, oldReverse] = await Promise.all([
|
|
3922
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3923
|
+
type: "control_request",
|
|
3924
|
+
operation: "turn.prompt",
|
|
3925
|
+
input: {},
|
|
3926
|
+
}),
|
|
3927
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3928
|
+
type: "query_request",
|
|
3929
|
+
query: "turn.prompt_status",
|
|
3930
|
+
input: {},
|
|
3931
|
+
}),
|
|
3932
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3933
|
+
type: "event_replay",
|
|
3934
|
+
sinceGeneration: 0,
|
|
3935
|
+
sinceSeq: 0,
|
|
3936
|
+
}),
|
|
3937
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3938
|
+
type: "register_provider",
|
|
3939
|
+
capability: "fs",
|
|
3940
|
+
definitions: {},
|
|
3941
|
+
}),
|
|
3942
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3943
|
+
type: "reverse_response",
|
|
3944
|
+
leaseId: "old-lease",
|
|
3945
|
+
ok: true,
|
|
3946
|
+
result: {},
|
|
3947
|
+
}),
|
|
3948
|
+
]);
|
|
3949
|
+
harness.sendToSession(`${operation}-provider-race`, { type: "provider_heartbeat", leaseId: "old-lease" });
|
|
3950
|
+
harness.sendToSession(`${operation}-provider-race`, { type: "lease_release", leaseId: "old-lease" });
|
|
3951
|
+
expect(
|
|
3952
|
+
[oldControl, oldQuery, oldReplay, oldProvider, oldReverse].map(
|
|
3953
|
+
response => response.error?.code ?? response.code,
|
|
3954
|
+
),
|
|
3955
|
+
).toEqual([
|
|
3956
|
+
"session_quiescing",
|
|
3957
|
+
"session_quiescing",
|
|
3958
|
+
"session_quiescing",
|
|
3959
|
+
"session_quiescing",
|
|
3960
|
+
"session_quiescing",
|
|
3961
|
+
]);
|
|
3962
|
+
expect(
|
|
3963
|
+
harness.sent(`${operation}-provider-race`).filter(frame => frame.type === "transport_error"),
|
|
3964
|
+
).toHaveLength(3);
|
|
3965
|
+
release.resolve();
|
|
3966
|
+
await Promise.all([end, lifecycleChange]);
|
|
3967
|
+
expect(timeoutWarnings).toBe(0);
|
|
3968
|
+
expect(diagnosticKeyCounts.at(-1)).toBe(0);
|
|
3969
|
+
const failure = harness.broadcasts.find(frame => {
|
|
3970
|
+
const payload = frame.payload;
|
|
3971
|
+
return (
|
|
3972
|
+
frame.kind === "agent_failed" &&
|
|
3973
|
+
typeof payload === "object" &&
|
|
3974
|
+
payload !== null &&
|
|
3975
|
+
(payload as { commandId?: unknown }).commandId === firstIds.commandId &&
|
|
3976
|
+
(payload as { turnId?: unknown }).turnId === firstIds.turnId
|
|
3977
|
+
);
|
|
3978
|
+
});
|
|
3979
|
+
expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code } } });
|
|
3980
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").length).toBeGreaterThan(0);
|
|
3981
|
+
|
|
3982
|
+
const successor = await harness.control("turn.prompt", { text: "successor" });
|
|
3983
|
+
expect(successor.ok).toBe(true);
|
|
3984
|
+
const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
|
|
3985
|
+
await harness.emit("agent_start");
|
|
3986
|
+
await harness.emit("agent_end", {
|
|
3987
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
|
|
3988
|
+
});
|
|
3989
|
+
secondInflight.resolve();
|
|
3990
|
+
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
|
|
3991
|
+
status: "terminal_ok",
|
|
3992
|
+
});
|
|
3993
|
+
firstInflight.resolve();
|
|
3994
|
+
expect(prompts).toBe(2);
|
|
3995
|
+
await harness.stop();
|
|
3996
|
+
} finally {
|
|
3997
|
+
await Bun.sleep(50);
|
|
3998
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
});
|
|
4002
|
+
|
|
4003
|
+
test.each([
|
|
4004
|
+
{ operation: "switchSession" as const, label: "session switch" },
|
|
4005
|
+
{ operation: "branch" as const, label: "session branch" },
|
|
4006
|
+
])("bounds a stalled 402/429 lifecycle drain during $label", async ({ operation }) => {
|
|
4007
|
+
for (const status of [402, 429]) {
|
|
4008
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-stall-`));
|
|
4009
|
+
try {
|
|
4010
|
+
const entered = Promise.withResolvers<void>();
|
|
4011
|
+
const release = Promise.withResolvers<void>();
|
|
4012
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
4013
|
+
let timeoutWarnings = 0;
|
|
4014
|
+
const harness = await invocationHarness(`${operation}-provider-stall`, cwd, {
|
|
4015
|
+
persistInterceptor: () => {},
|
|
4016
|
+
onLifecycleDrainTimeout: () => {
|
|
4017
|
+
timeoutWarnings += 1;
|
|
4018
|
+
},
|
|
4019
|
+
persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
|
|
4020
|
+
agentFailedWriteFailures: 0,
|
|
4021
|
+
sendUserMessage: async (_content, options) => {
|
|
4022
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4023
|
+
await firstInflight.promise;
|
|
4024
|
+
},
|
|
4025
|
+
});
|
|
4026
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
4027
|
+
expect(first.ok).toBe(true);
|
|
4028
|
+
await harness.emit("agent_start");
|
|
4029
|
+
const end = harness.emit("agent_end", {
|
|
4030
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
|
|
4031
|
+
});
|
|
4032
|
+
await entered.promise;
|
|
4033
|
+
const startedAt = Date.now();
|
|
4034
|
+
const lifecycleChange = harness[operation](`${operation}-stall-successor`);
|
|
4035
|
+
await lifecycleChange;
|
|
4036
|
+
expect(Date.now() - startedAt).toBeLessThan(1_500);
|
|
4037
|
+
expect(timeoutWarnings).toBe(1);
|
|
4038
|
+
release.resolve();
|
|
4039
|
+
firstInflight.resolve();
|
|
4040
|
+
await end;
|
|
4041
|
+
await harness.stop();
|
|
4042
|
+
} finally {
|
|
4043
|
+
await Bun.sleep(50);
|
|
4044
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
});
|
|
4048
|
+
|
|
4049
|
+
test("deduplicates repeated agent_failed diagnostics for one invocation", async () => {
|
|
4050
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-agent-failed-dedupe-"));
|
|
4051
|
+
try {
|
|
4052
|
+
const inflight = Promise.withResolvers<void>();
|
|
4053
|
+
const harness = await invocationHarness("agent-failed-dedupe", cwd, {
|
|
4054
|
+
sendUserMessage: async (_content, options) => {
|
|
4055
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4056
|
+
await inflight.promise;
|
|
4057
|
+
},
|
|
4058
|
+
});
|
|
4059
|
+
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
4060
|
+
expect(accepted.ok).toBe(true);
|
|
4061
|
+
const correlation = accepted.result;
|
|
4062
|
+
await harness.emit("agent_start");
|
|
4063
|
+
const failure = { error: Object.assign(new Error("provider failed"), { code: "provider_rejected" }) };
|
|
4064
|
+
await harness.emit("agent_failed", failure);
|
|
4065
|
+
await harness.emit("agent_failed", failure);
|
|
4066
|
+
expect(
|
|
4067
|
+
harness.broadcasts.filter(
|
|
4068
|
+
frame =>
|
|
4069
|
+
frame.kind === "agent_failed" &&
|
|
4070
|
+
(frame.payload as { commandId?: string }).commandId === correlation?.commandId &&
|
|
4071
|
+
(frame.payload as { turnId?: string }).turnId === correlation?.turnId,
|
|
4072
|
+
),
|
|
4073
|
+
).toHaveLength(1);
|
|
4074
|
+
await harness.emit("agent_end");
|
|
4075
|
+
inflight.resolve();
|
|
4076
|
+
await harness.stop();
|
|
4077
|
+
} finally {
|
|
4078
|
+
await Bun.sleep(50);
|
|
4079
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4080
|
+
}
|
|
4081
|
+
});
|
|
4082
|
+
|
|
4083
|
+
test("routes a delayed predecessor agent_end after replacement completion to the retired owner", async () => {
|
|
4084
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-retired-owner-"));
|
|
4085
|
+
try {
|
|
4086
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
4087
|
+
const secondInflight = Promise.withResolvers<void>();
|
|
4088
|
+
let prompts = 0;
|
|
4089
|
+
const harness = await invocationHarness("retired-owner-a", cwd, {
|
|
4090
|
+
persistInterceptor: () => {},
|
|
4091
|
+
sendUserMessage: async (_content, options) => {
|
|
4092
|
+
prompts += 1;
|
|
4093
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4094
|
+
if (prompts === 1) await firstInflight.promise;
|
|
4095
|
+
if (prompts === 2) await secondInflight.promise;
|
|
4096
|
+
},
|
|
4097
|
+
});
|
|
4098
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
4099
|
+
expect(first.ok).toBe(true);
|
|
4100
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
4101
|
+
await harness.emit("agent_start");
|
|
4102
|
+
await harness.switchSession("retired-owner-b");
|
|
4103
|
+
await harness.emit("agent_end", {
|
|
4104
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
|
|
4105
|
+
});
|
|
4106
|
+
const failure = harness.broadcasts.find(frame => {
|
|
4107
|
+
const payload = frame.payload;
|
|
4108
|
+
return (
|
|
4109
|
+
frame.kind === "agent_failed" &&
|
|
4110
|
+
typeof payload === "object" &&
|
|
4111
|
+
payload !== null &&
|
|
4112
|
+
(payload as { commandId?: unknown }).commandId === firstIds.commandId &&
|
|
4113
|
+
(payload as { turnId?: unknown }).turnId === firstIds.turnId
|
|
4114
|
+
);
|
|
4115
|
+
});
|
|
4116
|
+
expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code: "provider_http_402" } } });
|
|
4117
|
+
const successor = await harness.control("turn.prompt", { text: "successor" });
|
|
4118
|
+
expect(successor.ok).toBe(true);
|
|
4119
|
+
const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
|
|
4120
|
+
await harness.emit("agent_start");
|
|
4121
|
+
await harness.emit("agent_end", {
|
|
4122
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
|
|
4123
|
+
});
|
|
4124
|
+
secondInflight.resolve();
|
|
4125
|
+
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
|
|
4126
|
+
status: "terminal_ok",
|
|
4127
|
+
});
|
|
4128
|
+
firstInflight.resolve();
|
|
4129
|
+
expect(prompts).toBe(2);
|
|
4130
|
+
await harness.stop();
|
|
4131
|
+
} finally {
|
|
4132
|
+
await Bun.sleep(50);
|
|
4133
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4134
|
+
}
|
|
4135
|
+
});
|
|
4136
|
+
|
|
4137
|
+
test("fails closed for tokenless delayed events when a session id is reused", async () => {
|
|
4138
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-session-retired-owner-"));
|
|
4139
|
+
try {
|
|
4140
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
4141
|
+
const harness = await invocationHarness("reused-session", cwd, {
|
|
4142
|
+
persistInterceptor: () => {},
|
|
4143
|
+
sendUserMessage: async (_content, options) => {
|
|
4144
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4145
|
+
await firstInflight.promise;
|
|
4146
|
+
},
|
|
4147
|
+
});
|
|
4148
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
4149
|
+
expect(first.ok).toBe(true);
|
|
4150
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
4151
|
+
await harness.emit("agent_start");
|
|
4152
|
+
await harness.switchSession("different-session");
|
|
4153
|
+
await harness.switchSession("reused-session");
|
|
4154
|
+
await harness.emit("agent_start");
|
|
4155
|
+
await harness.emit("agent_end", {
|
|
4156
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
|
|
4157
|
+
});
|
|
4158
|
+
const failure = harness.broadcasts.find(frame => {
|
|
4159
|
+
const payload = frame.payload;
|
|
4160
|
+
return (
|
|
4161
|
+
frame.kind === "agent_failed" &&
|
|
4162
|
+
typeof payload === "object" &&
|
|
4163
|
+
payload !== null &&
|
|
4164
|
+
(payload as { commandId?: unknown }).commandId === firstIds.commandId &&
|
|
4165
|
+
(payload as { turnId?: unknown }).turnId === firstIds.turnId
|
|
4166
|
+
);
|
|
4167
|
+
});
|
|
4168
|
+
expect(failure).toBeUndefined();
|
|
4169
|
+
firstInflight.resolve();
|
|
4170
|
+
await harness.stop();
|
|
4171
|
+
} finally {
|
|
4172
|
+
await Bun.sleep(50);
|
|
4173
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4174
|
+
}
|
|
4175
|
+
});
|
|
4176
|
+
|
|
4177
|
+
test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
|
|
4178
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
|
|
4179
|
+
try {
|
|
4180
|
+
const harness = await invocationHarness("terminalize-prompt", cwd, {
|
|
4181
|
+
sendUserMessage: async (_content, options) => {
|
|
4182
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4183
|
+
await neverSettlingPromise();
|
|
4184
|
+
},
|
|
4185
|
+
});
|
|
4186
|
+
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
4187
|
+
expect(accepted.ok).toBe(true);
|
|
4188
|
+
const { commandId, turnId } = accepted.result ?? {};
|
|
4189
|
+
await harness.emit("agent_start");
|
|
4190
|
+
await harness.emit("agent_failed", {
|
|
4191
|
+
error: Object.assign(new Error("stream interrupted"), { code: "upstream_stream_interrupted" }),
|
|
4192
|
+
});
|
|
4193
|
+
await harness.emit("agent_end");
|
|
4194
|
+
// Provider text is redacted on the wire by contract (sanitizePromptFailure);
|
|
4195
|
+
// the failure reason survives as the safe-token code.
|
|
4196
|
+
expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
|
|
4197
|
+
status: "failed",
|
|
4198
|
+
error: { code: "upstream_stream_interrupted", message: "Prompt submission failed." },
|
|
4199
|
+
});
|
|
4200
|
+
await harness.stop();
|
|
4201
|
+
} finally {
|
|
4202
|
+
// Let the reconciliation store finish its atomic write before the state root disappears.
|
|
4203
|
+
await Bun.sleep(50);
|
|
4204
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4205
|
+
}
|
|
4206
|
+
});
|
|
4207
|
+
|
|
4208
|
+
test("a canceled prompt reports a terminal failed status instead of hanging", async () => {
|
|
4209
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-abort-"));
|
|
4210
|
+
try {
|
|
4211
|
+
const harness = await invocationHarness("terminalize-abort", cwd, {
|
|
4212
|
+
sendUserMessage: async (_content, options) => {
|
|
4213
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4214
|
+
await neverSettlingPromise();
|
|
4215
|
+
},
|
|
4216
|
+
abort: () => {},
|
|
4217
|
+
});
|
|
4218
|
+
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
4219
|
+
const { commandId, turnId } = accepted.result ?? {};
|
|
4220
|
+
await harness.emit("agent_start");
|
|
4221
|
+
await harness.emit("agent_failed", { error: Object.assign(new Error("turn aborted"), { code: "aborted" }) });
|
|
4222
|
+
await harness.emit("agent_end");
|
|
4223
|
+
expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
|
|
4224
|
+
status: "failed",
|
|
4225
|
+
error: { code: "aborted" },
|
|
4226
|
+
});
|
|
4227
|
+
await harness.stop();
|
|
4228
|
+
} finally {
|
|
4229
|
+
await Bun.sleep(50);
|
|
4230
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4231
|
+
}
|
|
4232
|
+
});
|
|
4233
|
+
test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
|
|
4234
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
|
|
4235
|
+
try {
|
|
4236
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
4237
|
+
const successorStarted = Promise.withResolvers<void>();
|
|
4238
|
+
let prompts = 0;
|
|
4239
|
+
const harness = await invocationHarness("abort-immediate-prompt", cwd, {
|
|
4240
|
+
sendUserMessage: async (_content, options) => {
|
|
4241
|
+
prompts += 1;
|
|
4242
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4243
|
+
if (prompts === 1) {
|
|
4244
|
+
await firstInflight.promise;
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
successorStarted.resolve();
|
|
4248
|
+
await Promise.withResolvers<void>().promise;
|
|
4249
|
+
},
|
|
4250
|
+
});
|
|
4251
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
4252
|
+
expect(first.ok).toBe(true);
|
|
4253
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
4254
|
+
await harness.emit("agent_start");
|
|
4255
|
+
expect(await harness.control("turn.abort", {})).toMatchObject({ ok: true });
|
|
4256
|
+
firstInflight.reject(Object.assign(new Error("turn aborted"), { code: "aborted" }));
|
|
4257
|
+
const second = await harness.control("turn.prompt", { text: "successor" });
|
|
4258
|
+
expect(second.ok).toBe(true);
|
|
4259
|
+
const secondIds = { commandId: second.result?.commandId, turnId: second.result?.turnId };
|
|
4260
|
+
expect(secondIds.commandId).not.toBe(firstIds.commandId);
|
|
4261
|
+
expect(secondIds.turnId).not.toBe(firstIds.turnId);
|
|
4262
|
+
await harness.emit("agent_start");
|
|
4263
|
+
await successorStarted.promise;
|
|
4264
|
+
await harness.emit("agent_end");
|
|
4265
|
+
expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
|
|
4266
|
+
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
4267
|
+
});
|
|
4268
|
+
expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
|
|
4269
|
+
status: "failed",
|
|
4270
|
+
error: { code: "aborted" },
|
|
4271
|
+
});
|
|
4272
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
3234
4273
|
expect(await settledStatus(harness, "turn.prompt_status", secondIds)).toMatchObject({
|
|
3235
4274
|
status: "terminal_ok",
|
|
3236
4275
|
});
|
|
@@ -3281,8 +4320,8 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3281
4320
|
expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
|
|
3282
4321
|
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
3283
4322
|
});
|
|
3284
|
-
await harness.emit("agent_end");
|
|
3285
|
-
await harness.emit("agent_end");
|
|
4323
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
4324
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
3286
4325
|
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
|
|
3287
4326
|
status: "terminal_ok",
|
|
3288
4327
|
});
|
|
@@ -3293,6 +4332,55 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3293
4332
|
}
|
|
3294
4333
|
});
|
|
3295
4334
|
|
|
4335
|
+
test("reused-session successor tool progress renews its active deadline", async () => {
|
|
4336
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-successor-progress-"));
|
|
4337
|
+
try {
|
|
4338
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
4339
|
+
const successorStarted = Promise.withResolvers<void>();
|
|
4340
|
+
let prompts = 0;
|
|
4341
|
+
const harness = await invocationHarness("reused-successor-progress", cwd, {
|
|
4342
|
+
settings: {
|
|
4343
|
+
get: (key: string) =>
|
|
4344
|
+
key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
4345
|
+
} as unknown as Settings,
|
|
4346
|
+
sendUserMessage: async (_content, options) => {
|
|
4347
|
+
prompts += 1;
|
|
4348
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4349
|
+
if (prompts === 1) {
|
|
4350
|
+
await firstInflight.promise;
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
4353
|
+
successorStarted.resolve();
|
|
4354
|
+
await Promise.withResolvers<void>().promise;
|
|
4355
|
+
},
|
|
4356
|
+
});
|
|
4357
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
4358
|
+
expect(first.ok).toBe(true);
|
|
4359
|
+
await harness.emit("agent_start");
|
|
4360
|
+
await harness.switchSession("reused-successor-progress-other");
|
|
4361
|
+
await harness.switchSession("reused-successor-progress");
|
|
4362
|
+
const successor = await harness.control("turn.prompt", { text: "successor" });
|
|
4363
|
+
expect(successor.ok).toBe(true);
|
|
4364
|
+
const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
|
|
4365
|
+
await harness.emit("agent_start");
|
|
4366
|
+
await successorStarted.promise;
|
|
4367
|
+
for (let index = 0; index < 4; index += 1) {
|
|
4368
|
+
await harness.emit("tool_execution_start");
|
|
4369
|
+
await Bun.sleep(15);
|
|
4370
|
+
}
|
|
4371
|
+
expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
|
|
4372
|
+
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
4373
|
+
});
|
|
4374
|
+
await harness.emit("agent_end");
|
|
4375
|
+
firstInflight.resolve();
|
|
4376
|
+
await harness.stop();
|
|
4377
|
+
expect(prompts).toBe(2);
|
|
4378
|
+
} finally {
|
|
4379
|
+
await Bun.sleep(50);
|
|
4380
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4381
|
+
}
|
|
4382
|
+
});
|
|
4383
|
+
|
|
3296
4384
|
test("abort_and_prompt starts exactly one successor and is not duplicated by delayed abort teardown", async () => {
|
|
3297
4385
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-and-prompt-once-"));
|
|
3298
4386
|
try {
|
|
@@ -3321,7 +4409,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3321
4409
|
expect(accepted.ok).toBe(true);
|
|
3322
4410
|
const successorIds = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3323
4411
|
await harness.emit("agent_start");
|
|
3324
|
-
await harness.emit("agent_end");
|
|
4412
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
3325
4413
|
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
|
|
3326
4414
|
status: "terminal_ok",
|
|
3327
4415
|
});
|
|
@@ -3339,7 +4427,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3339
4427
|
const harness = await invocationHarness("terminalize-skill", cwd, {
|
|
3340
4428
|
invokeSkill: async (_name, _args, options) => {
|
|
3341
4429
|
await options?.onPreflightAcceptCommit?.();
|
|
3342
|
-
await
|
|
4430
|
+
await neverSettlingPromise();
|
|
3343
4431
|
},
|
|
3344
4432
|
});
|
|
3345
4433
|
const accepted = await harness.control("skill.invoke", { name: "ralplan" });
|
|
@@ -3520,7 +4608,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3520
4608
|
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
3521
4609
|
const { commandId, turnId } = accepted.result ?? {};
|
|
3522
4610
|
await harness.emit("agent_start");
|
|
3523
|
-
await harness.emit("agent_end");
|
|
4611
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
3524
4612
|
const claimed = await settledStatus(harness, "turn.prompt_status", { commandId, turnId });
|
|
3525
4613
|
expect(claimed).toMatchObject({ status: "terminal_ok" });
|
|
3526
4614
|
inflight.reject(Object.assign(new Error("late provider failure"), { code: "upstream_error" }));
|
|
@@ -3563,7 +4651,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3563
4651
|
sendUserMessage: async (_content, options) => {
|
|
3564
4652
|
await options?.onPreflightAcceptCommit?.();
|
|
3565
4653
|
// Accepted, then permanently no execution progress and no agent_start.
|
|
3566
|
-
await
|
|
4654
|
+
await neverSettlingPromise();
|
|
3567
4655
|
},
|
|
3568
4656
|
});
|
|
3569
4657
|
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
@@ -3597,7 +4685,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3597
4685
|
settings: zeroProgressSettings,
|
|
3598
4686
|
sendUserMessage: async (_content, options) => {
|
|
3599
4687
|
await options?.onPreflightAcceptCommit?.();
|
|
3600
|
-
await
|
|
4688
|
+
await neverSettlingPromise();
|
|
3601
4689
|
},
|
|
3602
4690
|
});
|
|
3603
4691
|
const original = await harness.control("turn.prompt", { text: "original" });
|
|
@@ -3688,7 +4776,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3688
4776
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
3689
4777
|
expect(first.ok).toBe(true);
|
|
3690
4778
|
await harness.emit("agent_start");
|
|
3691
|
-
await harness.emit("agent_end");
|
|
4779
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
3692
4780
|
const followUpB = await harness.control("turn.follow_up", { text: "b" });
|
|
3693
4781
|
const followUpC = await harness.control("turn.follow_up", { text: "c" });
|
|
3694
4782
|
promoted[0]?.({ startsOwnRun: true });
|
|
@@ -3731,7 +4819,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3731
4819
|
return;
|
|
3732
4820
|
}
|
|
3733
4821
|
// The first turn accepts and then never makes progress.
|
|
3734
|
-
await
|
|
4822
|
+
await neverSettlingPromise();
|
|
3735
4823
|
},
|
|
3736
4824
|
});
|
|
3737
4825
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -3817,7 +4905,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3817
4905
|
)?.onQueuedPromoted;
|
|
3818
4906
|
return;
|
|
3819
4907
|
}
|
|
3820
|
-
await
|
|
4908
|
+
await neverSettlingPromise();
|
|
3821
4909
|
},
|
|
3822
4910
|
});
|
|
3823
4911
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -3836,7 +4924,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3836
4924
|
}
|
|
3837
4925
|
const midRun = await harness.query("turn.prompt_status", ids);
|
|
3838
4926
|
expect(midRun.result?.status).not.toBe("failed");
|
|
3839
|
-
await harness.emit("agent_end");
|
|
4927
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
3840
4928
|
const final = await harness.query("turn.prompt_status", ids);
|
|
3841
4929
|
expect(final.result?.status).toBe("terminal_ok");
|
|
3842
4930
|
await harness.stop();
|
|
@@ -3873,7 +4961,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3873
4961
|
promoted = options?.onQueuedPromoted;
|
|
3874
4962
|
return;
|
|
3875
4963
|
}
|
|
3876
|
-
await
|
|
4964
|
+
await neverSettlingPromise();
|
|
3877
4965
|
},
|
|
3878
4966
|
} as unknown as ExtensionAPI;
|
|
3879
4967
|
const transport = memoryTransport();
|
|
@@ -3931,7 +5019,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3931
5019
|
// The consuming run ends. The consumed submission must terminalize
|
|
3932
5020
|
// WITH it (terminal_ok, never a fabricated prompt_deadline_exceeded):
|
|
3933
5021
|
// with the bug it would still be parked in pending as merely accepted.
|
|
3934
|
-
await handlers.get("agent_end")?.({}, ctx);
|
|
5022
|
+
await handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "completed" }] }, ctx);
|
|
3935
5023
|
const statusOf = async (ids: { commandId?: string; turnId?: string }, frameId: string) => {
|
|
3936
5024
|
transport.feed("conn-a", {
|
|
3937
5025
|
type: "query_request",
|
|
@@ -3994,7 +5082,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3994
5082
|
return;
|
|
3995
5083
|
}
|
|
3996
5084
|
// The first turn accepts and then keeps streaming.
|
|
3997
|
-
await
|
|
5085
|
+
await neverSettlingPromise();
|
|
3998
5086
|
},
|
|
3999
5087
|
});
|
|
4000
5088
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -4009,7 +5097,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4009
5097
|
expect(midRun.result?.status).not.toBe("terminal_ok");
|
|
4010
5098
|
expect(midRun.result?.status).not.toBe("failed");
|
|
4011
5099
|
// The in-flight run ends: the diverted correlation terminalizes with it.
|
|
4012
|
-
await harness.emit("agent_end");
|
|
5100
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
4013
5101
|
expect(await settledStatus(harness, "turn.prompt_status", idsRaced)).toMatchObject({
|
|
4014
5102
|
status: "terminal_ok",
|
|
4015
5103
|
});
|
|
@@ -4052,7 +5140,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4052
5140
|
return;
|
|
4053
5141
|
}
|
|
4054
5142
|
// The first turn accepts and then keeps streaming.
|
|
4055
|
-
await
|
|
5143
|
+
await neverSettlingPromise();
|
|
4056
5144
|
},
|
|
4057
5145
|
});
|
|
4058
5146
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -4068,7 +5156,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4068
5156
|
expect(queued.result?.status).not.toBe("failed");
|
|
4069
5157
|
// Real consumption re-leases and attaches to the in-flight run.
|
|
4070
5158
|
consumed?.({ startsOwnRun: false });
|
|
4071
|
-
await harness.emit("agent_end");
|
|
5159
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
4072
5160
|
expect(await settledStatus(harness, "turn.prompt_status", idsRaced)).toMatchObject({
|
|
4073
5161
|
status: "terminal_ok",
|
|
4074
5162
|
});
|
|
@@ -4128,7 +5216,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4128
5216
|
} as unknown as Settings,
|
|
4129
5217
|
sendUserMessage: async (_content, options) => {
|
|
4130
5218
|
await options?.onPreflightAcceptCommit?.();
|
|
4131
|
-
await
|
|
5219
|
+
await neverSettlingPromise();
|
|
4132
5220
|
},
|
|
4133
5221
|
persistInterceptor: transition => {
|
|
4134
5222
|
if (transition.type === "agent_failed") {
|
|
@@ -4212,7 +5300,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4212
5300
|
const harness = await invocationHarness("skill-terminal-retry", cwd, {
|
|
4213
5301
|
invokeSkill: async (_name, _args, options) => {
|
|
4214
5302
|
await options?.onPreflightAcceptCommit?.();
|
|
4215
|
-
await
|
|
5303
|
+
await neverSettlingPromise();
|
|
4216
5304
|
},
|
|
4217
5305
|
persistInterceptor: transition => {
|
|
4218
5306
|
if (transition.type === "agent_end" && failedWrites < 2) {
|
|
@@ -4245,6 +5333,48 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4245
5333
|
}
|
|
4246
5334
|
});
|
|
4247
5335
|
|
|
5336
|
+
test("retains skill terminal recovery across session replacement", async () => {
|
|
5337
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-skill-terminal-replacement-"));
|
|
5338
|
+
let failFirstTerminalWrite = true;
|
|
5339
|
+
try {
|
|
5340
|
+
const harness = await invocationHarness("skill-terminal-replacement", cwd, {
|
|
5341
|
+
invokeSkill: async (_name, _args, options) => {
|
|
5342
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5343
|
+
await neverSettlingPromise();
|
|
5344
|
+
},
|
|
5345
|
+
persistInterceptor: transition => {
|
|
5346
|
+
if (transition.type === "agent_end" && failFirstTerminalWrite) {
|
|
5347
|
+
failFirstTerminalWrite = false;
|
|
5348
|
+
throw Object.assign(new Error("injected terminal persistence failure"), { code: "io_error" });
|
|
5349
|
+
}
|
|
5350
|
+
},
|
|
5351
|
+
});
|
|
5352
|
+
const submitted = await harness.control("skill.invoke", {
|
|
5353
|
+
name: "replacement-hang",
|
|
5354
|
+
args: "",
|
|
5355
|
+
clientRef: "skill-terminal-replacement-ref",
|
|
5356
|
+
});
|
|
5357
|
+
expect(submitted.ok).toBe(true);
|
|
5358
|
+
await harness.emit("agent_start");
|
|
5359
|
+
await harness.emit("agent_end");
|
|
5360
|
+
await Bun.sleep(50);
|
|
5361
|
+
await harness.switchSession("skill-terminal-replacement-next");
|
|
5362
|
+
await Bun.sleep(1_100);
|
|
5363
|
+
let settled: { status?: string; error?: { code?: string } } | undefined;
|
|
5364
|
+
for (let attempt = 0; attempt < 500 && settled === undefined; attempt += 1) {
|
|
5365
|
+
const frame = await harness.query("skill.invoke_status", { clientRef: "skill-terminal-replacement-ref" });
|
|
5366
|
+
const result = frame.result as { status?: string; error?: { code?: string } } | undefined;
|
|
5367
|
+
if (result?.status === "terminal_ok" || result?.status === "failed") settled = result;
|
|
5368
|
+
else await Bun.sleep(10);
|
|
5369
|
+
}
|
|
5370
|
+
expect(settled?.status).toBe("terminal_ok");
|
|
5371
|
+
await harness.stop();
|
|
5372
|
+
} finally {
|
|
5373
|
+
await Bun.sleep(50);
|
|
5374
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5375
|
+
}
|
|
5376
|
+
});
|
|
5377
|
+
|
|
4248
5378
|
test("a rejected prompt replays its failure reason, never a bare terminal_ok", async () => {
|
|
4249
5379
|
// Exact-head review HIGH: after failed agent_failed persistence, recovery
|
|
4250
5380
|
// replayed only agent_end, so an abandoned/rejected prompt became durable
|
|
@@ -4335,7 +5465,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4335
5465
|
)?.onQueuedPromoted;
|
|
4336
5466
|
return;
|
|
4337
5467
|
}
|
|
4338
|
-
await
|
|
5468
|
+
await neverSettlingPromise();
|
|
4339
5469
|
},
|
|
4340
5470
|
});
|
|
4341
5471
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -4369,7 +5499,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4369
5499
|
sendUserMessage: async (_content, options) => {
|
|
4370
5500
|
await options?.onPreflightAcceptCommit?.();
|
|
4371
5501
|
// The failing turn accepts and then never makes progress on its own.
|
|
4372
|
-
await
|
|
5502
|
+
await neverSettlingPromise();
|
|
4373
5503
|
},
|
|
4374
5504
|
});
|
|
4375
5505
|
const failing = await harness.control("turn.prompt", { text: "failing" });
|
|
@@ -4420,7 +5550,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4420
5550
|
options: { onPreflightAcceptCommit?: () => Promise<void> } | undefined,
|
|
4421
5551
|
) => {
|
|
4422
5552
|
await options?.onPreflightAcceptCommit?.();
|
|
4423
|
-
if (content === "hangs") await
|
|
5553
|
+
if (content === "hangs") await neverSettlingPromise();
|
|
4424
5554
|
},
|
|
4425
5555
|
} as unknown as ExtensionAPI;
|
|
4426
5556
|
const transport = memoryTransport();
|
|
@@ -4466,6 +5596,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4466
5596
|
const acceptedA = (await waitFrame("settle-early-a")) as { result?: { commandId?: string; turnId?: string } };
|
|
4467
5597
|
const idsA = { commandId: acceptedA.result?.commandId, turnId: acceptedA.result?.turnId };
|
|
4468
5598
|
const statusDeadline = Date.now() + 15_000;
|
|
5599
|
+
let settledStatus: string | undefined;
|
|
4469
5600
|
for (;;) {
|
|
4470
5601
|
transport.feed("conn-a", {
|
|
4471
5602
|
type: "query_request",
|
|
@@ -4477,10 +5608,12 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4477
5608
|
result?: { status?: string };
|
|
4478
5609
|
};
|
|
4479
5610
|
transport.sent.splice(transport.sent.indexOf(statusFrame), 1);
|
|
4480
|
-
|
|
5611
|
+
settledStatus = statusFrame.result?.status;
|
|
5612
|
+
if (statusFrame.result?.status === "terminal_ok" || statusFrame.result?.status === "failed") break;
|
|
4481
5613
|
if (Date.now() > statusDeadline) throw new Error("settled prompt never reported terminal_ok");
|
|
4482
5614
|
await Bun.sleep(20);
|
|
4483
5615
|
}
|
|
5616
|
+
expect(settledStatus).toBe("failed");
|
|
4484
5617
|
// conn-b's prompt is accepted and hangs; its run then starts.
|
|
4485
5618
|
transport.feed("conn-b", {
|
|
4486
5619
|
type: "control_request",
|
|
@@ -4818,7 +5951,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
|
|
|
4818
5951
|
// Production-faithful: the accepted run stays in-flight; sendUserMessage
|
|
4819
5952
|
// resolution (turn completion) never precedes agent_start (#4668
|
|
4820
5953
|
// success-retirement).
|
|
4821
|
-
await
|
|
5954
|
+
await neverSettlingPromise();
|
|
4822
5955
|
},
|
|
4823
5956
|
} as unknown as ExtensionAPI;
|
|
4824
5957
|
const transport = memoryTransport();
|
|
@@ -5155,7 +6288,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
|
|
|
5155
6288
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
5156
6289
|
if (options?.onQueuedPromoted) promoted.push(options.onQueuedPromoted);
|
|
5157
6290
|
options?.onPreflightAccepted?.();
|
|
5158
|
-
return
|
|
6291
|
+
return "completed";
|
|
5159
6292
|
}),
|
|
5160
6293
|
} as unknown as ExtensionAPI;
|
|
5161
6294
|
const transport = memoryTransport();
|
|
@@ -5243,7 +6376,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
|
|
|
5243
6376
|
// invocation left the remaining follow-ups durably accepted — their
|
|
5244
6377
|
// result lookups would never complete and a restart would report them
|
|
5245
6378
|
// failed even though they ran in the shared turn.
|
|
5246
|
-
await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
|
|
6379
|
+
await handlers.get("agent_end")?.({ type: "agent_end", stopReason: "cancelled", messages: [] }, ctx);
|
|
5247
6380
|
{
|
|
5248
6381
|
const terminalDeadline = Date.now() + 15_000;
|
|
5249
6382
|
const promptStatuses = () =>
|