@gajae-code/coding-agent 0.18.7 → 0.18.8
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 +279 -1
- package/dist/types/async/job-manager.d.ts +1 -1
- package/dist/types/capability/mcp.d.ts +2 -0
- package/dist/types/capability/skill.d.ts +2 -0
- package/dist/types/capability/types.d.ts +2 -0
- package/dist/types/cli/public-command-errors.d.ts +3 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/config/autorouting-tier-map.d.ts +348 -9
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-bindings-applier.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -3
- package/dist/types/config/model-registry.d.ts +4 -9
- package/dist/types/config/skill-settings-defaults.d.ts +2 -0
- package/dist/types/cursor-delete-path.d.ts +1 -0
- package/dist/types/cursor-mcp-name.d.ts +8 -0
- package/dist/types/customization/inventory.d.ts +2 -0
- package/dist/types/debug/report-bundle-parent.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
- package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
- package/dist/types/discovery/helpers.d.ts +6 -1
- package/dist/types/export/html/css-color.d.ts +2 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
- package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
- package/dist/types/extensibility/skill-management.d.ts +3 -1
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
- package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
- package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
- package/dist/types/internal-urls/skill-path.d.ts +1 -0
- package/dist/types/lsp/workspace-path.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
- package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +9 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
- package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
- package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
- package/dist/types/progress/collect-project-progress.d.ts +38 -0
- package/dist/types/progress/progress-contract.d.ts +154 -0
- package/dist/types/progress/progress-snapshot.d.ts +11 -0
- package/dist/types/progress/project-progress.d.ts +141 -0
- package/dist/types/progress/render-progress.d.ts +28 -0
- package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
- package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
- package/dist/types/runtime-mcp/types.d.ts +6 -0
- package/dist/types/sdk/acp/adapter.d.ts +1 -1
- package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
- package/dist/types/sdk/broker/broker.d.ts +18 -1
- package/dist/types/sdk/broker/session-index.d.ts +12 -6
- package/dist/types/sdk/bus/index.d.ts +12 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
- package/dist/types/sdk/client/client.d.ts +2 -0
- package/dist/types/sdk/host/query/handlers.d.ts +3 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +2 -0
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/sdk/session.d.ts +8 -0
- package/dist/types/session/agent-session.d.ts +38 -9
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
- package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +31 -5
- package/dist/types/session/session-retirement.d.ts +31 -7
- package/dist/types/session/session-storage.d.ts +62 -2
- package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
- package/dist/types/setup/credential-auto-import.d.ts +1 -1
- package/dist/types/setup/credential-import.d.ts +6 -4
- package/dist/types/skill-state/active-state.d.ts +38 -0
- package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/python.d.ts +9 -1
- package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
- package/dist/types/tools/restricted-cwd.d.ts +1 -0
- package/dist/types/tools/todo-write.d.ts +6 -0
- package/dist/types/utils/tools-checksum.d.ts +7 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +21 -0
- package/scripts/verify-managed-empty-stop.ts +28 -0
- package/src/async/job-manager.ts +228 -122
- package/src/capability/index.ts +17 -0
- package/src/capability/mcp.ts +2 -0
- package/src/capability/skill.ts +2 -0
- package/src/capability/types.ts +2 -0
- package/src/cli/auth-broker-cli.ts +1 -1
- package/src/cli/customize-doctor.ts +15 -2
- package/src/cli/public-command-errors.ts +21 -1
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/skills-cli.ts +5 -0
- package/src/commands/deep-interview.ts +47 -8
- package/src/commands/sdk.ts +28 -8
- package/src/config/autorouting-tier-map.ts +117 -7
- package/src/config/file-lock.ts +307 -68
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-bindings-applier.ts +20 -0
- package/src/config/model-profile-activation.ts +240 -46
- package/src/config/model-registry.ts +47 -153
- package/src/config/skill-settings-defaults.ts +7 -0
- package/src/coordinator-mcp/policy.ts +3 -2
- package/src/cursor-delete-path.ts +10 -0
- package/src/cursor-mcp-name.ts +15 -0
- package/src/cursor.ts +13 -2
- package/src/customization/inventory.ts +3 -0
- package/src/debug/report-bundle-parent.ts +22 -0
- package/src/debug/report-bundle.ts +3 -0
- package/src/debug/system-info.ts +13 -1
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
- package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
- package/src/defaults/gjc-defaults.test.ts +18 -1
- package/src/defaults/gjc-defaults.ts +3 -0
- package/src/defaults/gjc-skills.generated.ts +58 -36
- package/src/discovery/agents.ts +6 -1
- package/src/discovery/builtin.ts +10 -0
- package/src/discovery/helpers.ts +159 -20
- package/src/discovery/mcp-json.ts +6 -0
- package/src/discovery/ssh.ts +1 -1
- package/src/discovery/substitute-plugin-root.ts +3 -1
- package/src/edit/file-read-cache.ts +1 -1
- package/src/eval/py/executor.ts +380 -120
- package/src/export/html/css-color.ts +9 -0
- package/src/export/html/index.ts +5 -4
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
- package/src/extensibility/plugins/manager.ts +3 -2
- package/src/extensibility/plugins/marketplace/cache.ts +15 -0
- package/src/extensibility/plugins/marketplace/manager.ts +11 -3
- package/src/extensibility/plugins/plugin-link-path.ts +12 -0
- package/src/extensibility/runtime-skill-discovery.ts +20 -2
- package/src/extensibility/skill-management.ts +6 -2
- package/src/extensibility/skills.ts +6 -1
- package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
- package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
- package/src/gjc-runtime/deep-interview-stage.ts +98 -21
- package/src/gjc-runtime/gc-runtime.ts +206 -9
- package/src/gjc-runtime/launch-worktree.ts +14 -4
- package/src/gjc-runtime/ralplan-runtime.ts +447 -52
- package/src/gjc-runtime/state-argv.ts +2 -0
- package/src/gjc-runtime/state-migrations.ts +91 -29
- package/src/gjc-runtime/state-runtime.ts +3716 -135
- package/src/gjc-runtime/state-writer.ts +83 -8
- package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
- package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
- package/src/gjc-runtime/workflow-manifest.ts +54 -21
- package/src/harness-control-plane/operate.ts +12 -4
- package/src/harness-control-plane/owner.ts +5 -1
- package/src/hashline/parser.ts +48 -9
- package/src/hashline/recovery.ts +13 -4
- package/src/internal-urls/docs-index.generated.ts +18 -17
- package/src/internal-urls/skill-path.ts +19 -0
- package/src/internal-urls/skill-protocol.ts +2 -0
- package/src/lsp/edits.ts +6 -0
- package/src/lsp/workspace-path.ts +51 -0
- package/src/main.ts +195 -31
- package/src/modes/acp/acp-agent.ts +88 -4
- package/src/modes/components/custom-editor.ts +14 -0
- package/src/modes/components/customization/customization-dashboard.ts +12 -0
- package/src/modes/components/extensions/extension-dashboard.ts +2 -2
- package/src/modes/components/extensions/state-manager.ts +15 -5
- package/src/modes/components/model-selector.ts +1 -4
- package/src/modes/components/provider-onboarding-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +4 -0
- package/src/modes/controllers/event-controller.ts +11 -4
- package/src/modes/controllers/input-controller.ts +74 -29
- package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
- package/src/modes/controllers/selector-controller.ts +28 -15
- package/src/modes/interactive-mode.ts +4 -0
- package/src/modes/runtime-init.ts +4 -0
- package/src/modes/utils/abort-message.ts +3 -1
- package/src/modes/utils/interrupt-ownership.ts +12 -0
- package/src/modes/utils/keybinding-matchers.ts +4 -0
- package/src/modes/utils/macos-option-forwarding.ts +80 -0
- package/src/progress/collect-project-progress.ts +153 -0
- package/src/progress/progress-contract.ts +172 -0
- package/src/progress/progress-snapshot.ts +140 -0
- package/src/progress/project-progress.ts +446 -0
- package/src/progress/render-progress.ts +209 -0
- package/src/prompts/system/master-mode.md +1 -0
- package/src/prompts/tools/skill-discovery.md +1 -1
- package/src/runtime-mcp/config-writer.ts +10 -6
- package/src/runtime-mcp/config.ts +1 -0
- package/src/runtime-mcp/oauth-flow.ts +5 -0
- package/src/runtime-mcp/oauth-public-url.ts +8 -0
- package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
- package/src/runtime-mcp/smithery-registry.ts +22 -3
- package/src/runtime-mcp/transports/http.ts +2 -2
- package/src/runtime-mcp/types.ts +6 -0
- package/src/sdk/acp/adapter.ts +93 -8
- package/src/sdk/broker/broker-exit.ts +52 -23
- package/src/sdk/broker/broker.ts +75 -6
- package/src/sdk/broker/ensure.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +711 -66
- package/src/sdk/broker/session-index.ts +191 -81
- package/src/sdk/bus/chat-daemon-cli.ts +38 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +238 -43
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
- package/src/sdk/cli/session-cli.ts +17 -3
- package/src/sdk/client/client.ts +28 -8
- package/src/sdk/host/query/handlers.ts +9 -1
- package/src/sdk/host/session-runtime.test.ts +2647 -294
- package/src/sdk/host/session-runtime.ts +426 -109
- package/src/sdk/host/surface-policy.ts +1 -0
- package/src/sdk/index.ts +8 -0
- package/src/sdk/lifecycle-session.ts +3 -1
- package/src/sdk/prompt-deadline-manager.ts +15 -0
- package/src/sdk/protocol/operation-inventory.generated.json +146 -0
- package/src/sdk/protocol/operation-registry.ts +9 -2
- package/src/sdk/router/session-router.ts +75 -12
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +67 -38
- package/src/session/agent-session.ts +623 -224
- package/src/session/artifacts.ts +174 -70
- package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
- package/src/session/internal/managed-session-scope.ts +3457 -462
- package/src/session/internal/managed-session-storage.ts +376 -61
- package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
- package/src/session/internal/task-artifact-owner-access.ts +19 -3
- package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
- package/src/session/session-manager.ts +939 -133
- package/src/session/session-retirement.ts +573 -14
- package/src/session/session-storage.ts +417 -14
- package/src/session/task-artifact-owner-codec.ts +71 -8
- package/src/session/task-artifact-owner-retirement.ts +21 -19
- package/src/setup/credential-auto-import.ts +12 -3
- package/src/setup/credential-import.ts +176 -5
- package/src/setup/paseo/announce.ts +6 -1
- package/src/skill-state/active-state.ts +132 -2
- package/src/slash-commands/builtin-registry.ts +39 -9
- package/src/slash-commands/helpers/progress-report.ts +23 -0
- package/src/stt/setup.ts +3 -5
- package/src/task/commands.ts +3 -1
- package/src/task/executor.ts +4 -0
- package/src/task/index.ts +6 -0
- package/src/tools/ask.ts +205 -6
- package/src/tools/ast-edit.ts +4 -0
- package/src/tools/bash-allowed-prefixes.ts +15 -0
- package/src/tools/bash-skill-urls.ts +56 -3
- package/src/tools/bash.ts +48 -79
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/eval.ts +56 -36
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/python.ts +187 -66
- package/src/tools/recipe/index.ts +9 -0
- package/src/tools/recipe/recipe-guard.ts +19 -0
- package/src/tools/restricted-cwd.ts +10 -0
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +28 -1
- package/src/tools/todo-write.ts +10 -1
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/write.ts +1 -0
- package/src/utils/tools-checksum.ts +34 -0
- package/src/utils/tools-manager.ts +24 -6
- package/src/web/search/providers/codex.ts +3 -2
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
4
|
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import * as os from "node:os";
|
|
5
6
|
import * as path from "node:path";
|
|
6
|
-
import { markNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
|
|
7
|
+
import { Agent, type AgentOptions, markNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
|
|
8
|
+
import { createAttemptMinter } from "@gajae-code/agent-core/attempt-scope";
|
|
9
|
+
import { CompactionCancelledError } from "@gajae-code/agent-core/compaction";
|
|
10
|
+
import { type AssistantMessage, getBundledModel, type Model, type UserMessage } from "@gajae-code/ai/core";
|
|
11
|
+
import { createMockModel } from "@gajae-code/ai/providers/mock";
|
|
12
|
+
import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream";
|
|
7
13
|
import { logger } from "@gajae-code/utils";
|
|
8
14
|
import { createTestSession } from "../../../test/utilities";
|
|
9
15
|
import { AsyncJobManager } from "../../async";
|
|
10
|
-
import
|
|
16
|
+
import { ModelRegistry } from "../../config/model-registry";
|
|
17
|
+
import { Settings } from "../../config/settings";
|
|
11
18
|
import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
|
|
19
|
+
import { ExtensionRunner } from "../../extensibility/extensions/runner";
|
|
20
|
+
import { AgentSession } from "../../session/agent-session";
|
|
21
|
+
import { AuthStorage } from "../../session/auth-storage";
|
|
22
|
+
import { SessionManager } from "../../session/session-manager";
|
|
12
23
|
import {
|
|
13
24
|
registerOwnedRegistration,
|
|
14
25
|
resetTerminalAbortRegistriesForTests,
|
|
@@ -16,14 +27,21 @@ import {
|
|
|
16
27
|
unregisterOwnedRegistration,
|
|
17
28
|
} from "../../session/terminal-abort";
|
|
18
29
|
import { Broker } from "../broker/broker";
|
|
30
|
+
import type { BrokerDiscovery } from "../broker/discovery";
|
|
31
|
+
import { SessionIndex, type SessionIndexEvent } from "../broker/session-index";
|
|
19
32
|
import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
|
|
20
33
|
import { createPromptReconciliation } from "../bus/prompt-reconciliation";
|
|
21
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
createReconciliationStore,
|
|
36
|
+
type ReconciliationStoreDocument,
|
|
37
|
+
reconciliationStorePath,
|
|
38
|
+
} from "../bus/reconciliation-store";
|
|
22
39
|
import { PromptDeadlineManager } from "../prompt-deadline-manager";
|
|
23
40
|
import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
|
|
24
41
|
import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
|
|
25
42
|
import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
|
|
26
43
|
import {
|
|
44
|
+
type CreateSdkSessionRuntimeOptions,
|
|
27
45
|
createInvocationReconciliation,
|
|
28
46
|
createSdkSessionRuntimeExtension,
|
|
29
47
|
createSdkSurfaceFactory,
|
|
@@ -1693,7 +1711,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1693
1711
|
sendUserMessage: async () => {},
|
|
1694
1712
|
} as unknown as ExtensionAPI;
|
|
1695
1713
|
const transport = memoryTransport();
|
|
1696
|
-
|
|
1714
|
+
createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
1697
1715
|
const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
|
|
1698
1716
|
const ctx = {
|
|
1699
1717
|
...extensionContext(transport.sessionId, cwd),
|
|
@@ -1749,7 +1767,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1749
1767
|
}
|
|
1750
1768
|
broadcastFrame?.(frame);
|
|
1751
1769
|
};
|
|
1752
|
-
|
|
1770
|
+
createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
1753
1771
|
const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
|
|
1754
1772
|
const ctx = {
|
|
1755
1773
|
...extensionContext(transport.sessionId, cwd),
|
|
@@ -1810,7 +1828,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1810
1828
|
let activeHandle: string | undefined = "exact-run-handle";
|
|
1811
1829
|
let activeEpoch: number | undefined = 7;
|
|
1812
1830
|
let captureCalls = 0;
|
|
1813
|
-
|
|
1831
|
+
createTestRuntimeExtension(api, {
|
|
1814
1832
|
agentDir: cwd,
|
|
1815
1833
|
createTransport: async () => transport,
|
|
1816
1834
|
onSdkRequest: undefined,
|
|
@@ -2009,7 +2027,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2009
2027
|
});
|
|
2010
2028
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2011
2029
|
let captureCalls = 0;
|
|
2012
|
-
|
|
2030
|
+
createTestRuntimeExtension(api, {
|
|
2013
2031
|
agentDir: cwd,
|
|
2014
2032
|
createTransport: async () => transport,
|
|
2015
2033
|
terminalAbortSeams: {
|
|
@@ -2106,7 +2124,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2106
2124
|
// exactly the window where writeNoEffect awaits the store.
|
|
2107
2125
|
let promptReads = 0;
|
|
2108
2126
|
let captureCalls = 0;
|
|
2109
|
-
|
|
2127
|
+
createTestRuntimeExtension(api, {
|
|
2110
2128
|
agentDir: cwd,
|
|
2111
2129
|
createTransport: async () => transport,
|
|
2112
2130
|
terminalAbortSeams: {
|
|
@@ -2191,7 +2209,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2191
2209
|
});
|
|
2192
2210
|
const seamCalls: string[] = [];
|
|
2193
2211
|
let promptReads = 0;
|
|
2194
|
-
|
|
2212
|
+
createTestRuntimeExtension(api, {
|
|
2195
2213
|
agentDir: cwd,
|
|
2196
2214
|
createTransport: async () => transport,
|
|
2197
2215
|
terminalAbortSeams: {
|
|
@@ -2261,7 +2279,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2261
2279
|
// (review thread P1).
|
|
2262
2280
|
let reads = 0;
|
|
2263
2281
|
let captureCalls = 0;
|
|
2264
|
-
|
|
2282
|
+
createTestRuntimeExtension(api, {
|
|
2265
2283
|
agentDir: cwd,
|
|
2266
2284
|
createTransport: async () => transport,
|
|
2267
2285
|
terminalAbortSeams: {
|
|
@@ -2354,7 +2372,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2354
2372
|
sessionId: transport.sessionId,
|
|
2355
2373
|
});
|
|
2356
2374
|
let captureCalls = 0;
|
|
2357
|
-
|
|
2375
|
+
createTestRuntimeExtension(api, {
|
|
2358
2376
|
agentDir: cwd,
|
|
2359
2377
|
createTransport: async () => transport,
|
|
2360
2378
|
terminalAbortSeams: {
|
|
@@ -2421,7 +2439,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2421
2439
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2422
2440
|
let seamCount = 0;
|
|
2423
2441
|
let captureCalls = 0;
|
|
2424
|
-
|
|
2442
|
+
createTestRuntimeExtension(api, {
|
|
2425
2443
|
agentDir: cwd,
|
|
2426
2444
|
createTransport: async () => transport,
|
|
2427
2445
|
terminalAbortSeams: {
|
|
@@ -2530,7 +2548,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2530
2548
|
let activeHandle = "predecessor";
|
|
2531
2549
|
let abortCalls = 0;
|
|
2532
2550
|
const abortRelease = Promise.withResolvers<void>();
|
|
2533
|
-
|
|
2551
|
+
createTestRuntimeExtension(api, {
|
|
2534
2552
|
agentDir: cwd,
|
|
2535
2553
|
createTransport: async () => transport,
|
|
2536
2554
|
terminalAbortSeams: {
|
|
@@ -2618,7 +2636,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2618
2636
|
sessionFile: path.join(cwd, "session.json"),
|
|
2619
2637
|
sessionId: transport.sessionId,
|
|
2620
2638
|
});
|
|
2621
|
-
|
|
2639
|
+
createTestRuntimeExtension(api, {
|
|
2622
2640
|
agentDir: cwd,
|
|
2623
2641
|
createTransport: async () => transport,
|
|
2624
2642
|
terminalAbortSeams: {
|
|
@@ -2665,24 +2683,38 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2665
2683
|
}
|
|
2666
2684
|
});
|
|
2667
2685
|
|
|
2668
|
-
test(
|
|
2686
|
+
test.each([
|
|
2687
|
+
"ordinary",
|
|
2688
|
+
"terminal",
|
|
2689
|
+
] as const)("SDK-only %s abort cancels only its admitted preflight snapshot", async mode => {
|
|
2669
2690
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-preflight-snapshot-"));
|
|
2691
|
+
const signals = new Map<string, AbortSignal>();
|
|
2692
|
+
const acceptCallbacks = new Map<string, () => void | Promise<void>>();
|
|
2693
|
+
let rootAbortCalls = 0;
|
|
2670
2694
|
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2671
2695
|
const api = {
|
|
2672
2696
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2673
2697
|
handlers.set(event, handler);
|
|
2674
2698
|
},
|
|
2675
|
-
//
|
|
2676
|
-
//
|
|
2677
|
-
|
|
2678
|
-
|
|
2699
|
+
// Keep actual admission capabilities pending, rather than inventing
|
|
2700
|
+
// an acceptance or terminal. Record before the runtime can reject.
|
|
2701
|
+
sendUserMessage: (
|
|
2702
|
+
content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
|
|
2703
|
+
options: PreflightHooks | undefined,
|
|
2704
|
+
) => {
|
|
2705
|
+
const text = typeof content === "string" ? content : content.find(block => block.type === "text")?.text;
|
|
2706
|
+
if (text !== undefined && options?.preflightSignal) signals.set(text, options.preflightSignal);
|
|
2707
|
+
if (text !== undefined && options?.onPreflightAcceptCommit)
|
|
2708
|
+
acceptCallbacks.set(text, options.onPreflightAcceptCommit);
|
|
2709
|
+
return Promise.withResolvers<void>().promise;
|
|
2710
|
+
},
|
|
2679
2711
|
} as unknown as ExtensionAPI;
|
|
2680
2712
|
const transport = memoryTransport();
|
|
2681
2713
|
const reconciliationStore = createReconciliationStore({
|
|
2682
2714
|
sessionFile: path.join(cwd, "session.json"),
|
|
2683
2715
|
sessionId: transport.sessionId,
|
|
2684
2716
|
});
|
|
2685
|
-
|
|
2717
|
+
createTestRuntimeExtension(api, {
|
|
2686
2718
|
agentDir: cwd,
|
|
2687
2719
|
createTransport: async () => transport,
|
|
2688
2720
|
terminalAbortSeams: {
|
|
@@ -2697,7 +2729,12 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2697
2729
|
},
|
|
2698
2730
|
},
|
|
2699
2731
|
});
|
|
2700
|
-
const ctx =
|
|
2732
|
+
const ctx = {
|
|
2733
|
+
...extensionContext(transport.sessionId, cwd),
|
|
2734
|
+
abort: async () => {
|
|
2735
|
+
rootAbortCalls++;
|
|
2736
|
+
},
|
|
2737
|
+
};
|
|
2701
2738
|
try {
|
|
2702
2739
|
await handlers.get("session_start")?.({}, ctx);
|
|
2703
2740
|
const prompt = (id: string) =>
|
|
@@ -2708,14 +2745,22 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2708
2745
|
input: { text: `hold-${id}`, images: [] },
|
|
2709
2746
|
} as SdkFrame);
|
|
2710
2747
|
prompt("pre-1");
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2748
|
+
transport.feed("conn-b", {
|
|
2749
|
+
type: "control_request",
|
|
2750
|
+
id: "foreign-preflight",
|
|
2751
|
+
operation: "turn.prompt",
|
|
2752
|
+
input: { text: "foreign-pending", images: [] },
|
|
2753
|
+
} as SdkFrame);
|
|
2754
|
+
const readyDeadline = Date.now() + 2_000;
|
|
2755
|
+
while (!signals.has("hold-pre-1")) {
|
|
2756
|
+
if (Date.now() >= readyDeadline) throw new Error("Owned preflight was not admitted.");
|
|
2757
|
+
await Bun.sleep(10);
|
|
2758
|
+
}
|
|
2714
2759
|
transport.feed("conn-a", {
|
|
2715
2760
|
type: "control_request",
|
|
2716
2761
|
id: "pre-abort",
|
|
2717
2762
|
operation: "turn.abort",
|
|
2718
|
-
input: { mode: "terminal" },
|
|
2763
|
+
input: mode === "terminal" ? { mode: "terminal" } : {},
|
|
2719
2764
|
idempotencyKey: "pre-snapshot-key",
|
|
2720
2765
|
} as SdkFrame);
|
|
2721
2766
|
// A successor prompt pipelined by the SAME connection while the abort
|
|
@@ -2725,12 +2770,30 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2725
2770
|
const deadline = Date.now() + 15_000;
|
|
2726
2771
|
while (
|
|
2727
2772
|
!transport.sent.some(frame => frame.id === "pre-abort" && frame.type === "control_response") ||
|
|
2728
|
-
!transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response")
|
|
2773
|
+
!transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response") ||
|
|
2774
|
+
!signals.has("foreign-pending")
|
|
2729
2775
|
) {
|
|
2730
2776
|
if (Date.now() > deadline)
|
|
2731
2777
|
throw new Error("Timed out waiting for the abort and admitted-preflight responses");
|
|
2732
2778
|
await Bun.sleep(20);
|
|
2733
2779
|
}
|
|
2780
|
+
expect(rootAbortCalls).toBe(0);
|
|
2781
|
+
expect(signals.get("hold-pre-1")?.aborted).toBe(true);
|
|
2782
|
+
expect(signals.get("foreign-pending")?.aborted).toBe(false);
|
|
2783
|
+
// Prompt controls are serialized. Commit the untouched foreign
|
|
2784
|
+
// admission through its real callback so the later request can enter.
|
|
2785
|
+
const acceptForeign = acceptCallbacks.get("foreign-pending");
|
|
2786
|
+
expect(acceptForeign).toBeDefined();
|
|
2787
|
+
await acceptForeign!();
|
|
2788
|
+
while (!signals.has("hold-pre-2")) {
|
|
2789
|
+
if (Date.now() > deadline) throw new Error("Later admission did not enter after foreign acceptance.");
|
|
2790
|
+
await Bun.sleep(10);
|
|
2791
|
+
}
|
|
2792
|
+
expect(signals.get("hold-pre-2")?.aborted).toBe(false);
|
|
2793
|
+
expect(transport.sent.find(frame => frame.id === "foreign-preflight")).toMatchObject({
|
|
2794
|
+
ok: true,
|
|
2795
|
+
result: { accepted: true },
|
|
2796
|
+
});
|
|
2734
2797
|
// The admitted preflight is cancelled by the abort.
|
|
2735
2798
|
expect(transport.sent.find(frame => frame.id === "pre-1")).toMatchObject({
|
|
2736
2799
|
ok: false,
|
|
@@ -2738,7 +2801,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2738
2801
|
});
|
|
2739
2802
|
expect(transport.sent.find(frame => frame.id === "pre-abort")).toMatchObject({
|
|
2740
2803
|
ok: true,
|
|
2741
|
-
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
2804
|
+
result: expect.objectContaining(mode === "terminal" ? { turn: "no_active_turn" } : { aborted: true }),
|
|
2742
2805
|
});
|
|
2743
2806
|
// The successor preflight was NOT part of the abort's snapshot: it is
|
|
2744
2807
|
// neither cancelled nor failed, so no control response is emitted for it.
|
|
@@ -2763,7 +2826,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2763
2826
|
sessionId: transport.sessionId,
|
|
2764
2827
|
});
|
|
2765
2828
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2766
|
-
|
|
2829
|
+
createTestRuntimeExtension(api, {
|
|
2767
2830
|
agentDir: cwd,
|
|
2768
2831
|
createTransport: async () => transport,
|
|
2769
2832
|
terminalAbortSeams: {
|
|
@@ -2881,7 +2944,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2881
2944
|
sessionId: transport.sessionId,
|
|
2882
2945
|
});
|
|
2883
2946
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2884
|
-
|
|
2947
|
+
createTestRuntimeExtension(api, {
|
|
2885
2948
|
agentDir: cwd,
|
|
2886
2949
|
createTransport: async () => transport,
|
|
2887
2950
|
terminalAbortSeams: {
|
|
@@ -2972,12 +3035,10 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2972
3035
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2973
3036
|
handlers.set(event, handler);
|
|
2974
3037
|
},
|
|
2975
|
-
sendUserMessage: (
|
|
2976
|
-
_content: string,
|
|
2977
|
-
options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
|
|
2978
|
-
) =>
|
|
3038
|
+
sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
|
|
2979
3039
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
2980
3040
|
options?.onPreflightAccepted?.();
|
|
3041
|
+
if (!idle) options?.onQueuedPromoted?.({ startsOwnRun: false });
|
|
2981
3042
|
return {};
|
|
2982
3043
|
}),
|
|
2983
3044
|
} as unknown as ExtensionAPI;
|
|
@@ -2988,7 +3049,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2988
3049
|
});
|
|
2989
3050
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2990
3051
|
let idle = true;
|
|
2991
|
-
|
|
3052
|
+
createTestRuntimeExtension(api, {
|
|
2992
3053
|
agentDir: cwd,
|
|
2993
3054
|
createTransport: async () => transport,
|
|
2994
3055
|
terminalAbortSeams: {
|
|
@@ -3067,11 +3128,18 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3067
3128
|
handlers.set(event, handler);
|
|
3068
3129
|
},
|
|
3069
3130
|
sendUserMessage: (
|
|
3070
|
-
|
|
3071
|
-
options:
|
|
3131
|
+
content: string,
|
|
3132
|
+
options:
|
|
3133
|
+
| {
|
|
3134
|
+
onPreflightAccepted?: () => void;
|
|
3135
|
+
onPreflightAcceptCommit?: () => void;
|
|
3136
|
+
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
3137
|
+
}
|
|
3138
|
+
| undefined,
|
|
3072
3139
|
) =>
|
|
3073
3140
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
3074
3141
|
options?.onPreflightAccepted?.();
|
|
3142
|
+
if (content === "follow up b") options?.onQueuedPromoted?.({ startsOwnRun: false });
|
|
3075
3143
|
return {};
|
|
3076
3144
|
}),
|
|
3077
3145
|
} as unknown as ExtensionAPI;
|
|
@@ -3082,7 +3150,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3082
3150
|
});
|
|
3083
3151
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3084
3152
|
let idle = true;
|
|
3085
|
-
|
|
3153
|
+
createTestRuntimeExtension(api, {
|
|
3086
3154
|
agentDir: cwd,
|
|
3087
3155
|
createTransport: async () => transport,
|
|
3088
3156
|
terminalAbortSeams: {
|
|
@@ -3241,7 +3309,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3241
3309
|
sessionId: transport.sessionId,
|
|
3242
3310
|
});
|
|
3243
3311
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3244
|
-
|
|
3312
|
+
createTestRuntimeExtension(api, {
|
|
3245
3313
|
agentDir: cwd,
|
|
3246
3314
|
createTransport: async () => transport,
|
|
3247
3315
|
terminalAbortSeams: {
|
|
@@ -3361,7 +3429,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3361
3429
|
sessionId: transport.sessionId,
|
|
3362
3430
|
});
|
|
3363
3431
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3364
|
-
|
|
3432
|
+
createTestRuntimeExtension(api, {
|
|
3365
3433
|
agentDir: cwd,
|
|
3366
3434
|
createTransport: async () => transport,
|
|
3367
3435
|
terminalAbortSeams: {
|
|
@@ -3423,7 +3491,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3423
3491
|
sessionFile: path.join(cwd, "session.json"),
|
|
3424
3492
|
sessionId: transport.sessionId,
|
|
3425
3493
|
});
|
|
3426
|
-
|
|
3494
|
+
createTestRuntimeExtension(api, {
|
|
3427
3495
|
agentDir: cwd,
|
|
3428
3496
|
createTransport: async () => transport,
|
|
3429
3497
|
terminalAbortSeams: {
|
|
@@ -3514,7 +3582,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3514
3582
|
sessionFile: path.join(cwd, "session.json"),
|
|
3515
3583
|
sessionId: transport.sessionId,
|
|
3516
3584
|
});
|
|
3517
|
-
|
|
3585
|
+
createTestRuntimeExtension(api, {
|
|
3518
3586
|
agentDir: cwd,
|
|
3519
3587
|
createTransport: async () => transport,
|
|
3520
3588
|
terminalAbortSeams: {
|
|
@@ -3606,7 +3674,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3606
3674
|
sessionId: transport.sessionId,
|
|
3607
3675
|
});
|
|
3608
3676
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3609
|
-
|
|
3677
|
+
createTestRuntimeExtension(api, {
|
|
3610
3678
|
agentDir: cwd,
|
|
3611
3679
|
createTransport: async () => transport,
|
|
3612
3680
|
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
@@ -3697,7 +3765,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3697
3765
|
sessionId: transport.sessionId,
|
|
3698
3766
|
});
|
|
3699
3767
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3700
|
-
|
|
3768
|
+
createTestRuntimeExtension(api, {
|
|
3701
3769
|
agentDir: cwd,
|
|
3702
3770
|
createTransport: async () => transport,
|
|
3703
3771
|
terminalAbortSeams: {
|
|
@@ -3793,7 +3861,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3793
3861
|
};
|
|
3794
3862
|
registerOwnedRegistration(registration as never, { isJobTerminal: () => false });
|
|
3795
3863
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3796
|
-
|
|
3864
|
+
createTestRuntimeExtension(api, {
|
|
3797
3865
|
agentDir: cwd,
|
|
3798
3866
|
createTransport: async () => transport,
|
|
3799
3867
|
terminalAbortSeams: {
|
|
@@ -3868,7 +3936,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3868
3936
|
sessionFile: path.join(cwd, "session.json"),
|
|
3869
3937
|
sessionId: transport.sessionId,
|
|
3870
3938
|
});
|
|
3871
|
-
|
|
3939
|
+
createTestRuntimeExtension(api, {
|
|
3872
3940
|
agentDir: cwd,
|
|
3873
3941
|
createTransport: async () => transport,
|
|
3874
3942
|
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
@@ -3929,7 +3997,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3929
3997
|
sessionFile: path.join(cwd, "session.json"),
|
|
3930
3998
|
sessionId: transport.sessionId,
|
|
3931
3999
|
});
|
|
3932
|
-
|
|
4000
|
+
createTestRuntimeExtension(api, {
|
|
3933
4001
|
agentDir: cwd,
|
|
3934
4002
|
createTransport: async () => transport,
|
|
3935
4003
|
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
@@ -4019,7 +4087,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4019
4087
|
},
|
|
4020
4088
|
} as unknown as ExtensionAPI;
|
|
4021
4089
|
const transports: Array<{ starts: number; stops: number }> = [];
|
|
4022
|
-
|
|
4090
|
+
createTestRuntimeExtension(api, {
|
|
4023
4091
|
agentDir: path.join(cwd, ".gjc", "agent"),
|
|
4024
4092
|
createTransport: async ({ sessionId, stateRoot, token }) => {
|
|
4025
4093
|
const stats = { starts: 0, stops: 0 };
|
|
@@ -4090,8 +4158,12 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4090
4158
|
} as any;
|
|
4091
4159
|
const sessionId = "broker-recovery";
|
|
4092
4160
|
const endpointUrl = "ws://127.0.0.1:1";
|
|
4093
|
-
|
|
4161
|
+
createTestRuntimeExtension(api, {
|
|
4094
4162
|
agentDir,
|
|
4163
|
+
ensureBrokerImpl: async () => {
|
|
4164
|
+
if (!broker) throw Object.assign(new Error("injected unavailable broker"), { code: "acquire_timeout" });
|
|
4165
|
+
return testBrokerDiscovery;
|
|
4166
|
+
},
|
|
4095
4167
|
createTransport: async ({ stateRoot, token }) => ({
|
|
4096
4168
|
sessionId,
|
|
4097
4169
|
stateRoot,
|
|
@@ -4116,13 +4188,19 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4116
4188
|
await chmod(agentDir, 0o700);
|
|
4117
4189
|
broker = new Broker({ agentDir });
|
|
4118
4190
|
await broker.start();
|
|
4191
|
+
// Optional registration now completes independently of the event handler.
|
|
4192
|
+
const deadline = Date.now() + 15_000;
|
|
4119
4193
|
await handlers.get("turn_start")?.({}, context);
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
}
|
|
4125
|
-
|
|
4194
|
+
let endpoint = await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 });
|
|
4195
|
+
while (!endpoint.ok && Date.now() < deadline) {
|
|
4196
|
+
await Bun.sleep(20);
|
|
4197
|
+
await handlers.get("turn_start")?.({}, context);
|
|
4198
|
+
endpoint = await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 });
|
|
4199
|
+
}
|
|
4200
|
+
expect(endpoint).toMatchObject({
|
|
4201
|
+
ok: true,
|
|
4202
|
+
result: { sessionId, token: expect.any(String) },
|
|
4203
|
+
});
|
|
4126
4204
|
await handlers.get("session_shutdown")?.({}, context);
|
|
4127
4205
|
// DR-1 keeps the unregistered row listed, so the two refusals stay distinct:
|
|
4128
4206
|
// a matching generation on a terminal row is terminally gone (no endpoint will
|
|
@@ -4146,6 +4224,185 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4146
4224
|
}
|
|
4147
4225
|
});
|
|
4148
4226
|
|
|
4227
|
+
test("optional blocked broker registration leaves startup and turns responsive and fences shutdown", async () => {
|
|
4228
|
+
const gate = Promise.withResolvers<BrokerDiscovery>();
|
|
4229
|
+
let attempts = 0;
|
|
4230
|
+
const harness = await brokerRegistrationHarness({
|
|
4231
|
+
ensureBrokerImpl: () => {
|
|
4232
|
+
attempts += 1;
|
|
4233
|
+
return gate.promise;
|
|
4234
|
+
},
|
|
4235
|
+
});
|
|
4236
|
+
const publication = spyOn(SessionSdkSessionRuntime.prototype, "registerWithBroker");
|
|
4237
|
+
try {
|
|
4238
|
+
await harness.emit("session_start");
|
|
4239
|
+
await Promise.all([harness.emit("turn_start"), harness.emit("turn_start")]);
|
|
4240
|
+
expect(attempts).toBe(1);
|
|
4241
|
+
expect(harness.turnStarts()).toBe(2);
|
|
4242
|
+
// Recovery arms only once the startup attempt settles.
|
|
4243
|
+
expect(harness.recoveryActive()).toBe(false);
|
|
4244
|
+
await harness.emit("session_shutdown");
|
|
4245
|
+
expect(harness.stops()).toBe(1);
|
|
4246
|
+
gate.resolve(testBrokerDiscovery);
|
|
4247
|
+
await Bun.sleep(0);
|
|
4248
|
+
await harness.emit("turn_start");
|
|
4249
|
+
expect(attempts).toBe(1);
|
|
4250
|
+
expect(publication).not.toHaveBeenCalled();
|
|
4251
|
+
// An attempt that settles after shutdown must not arm recovery either.
|
|
4252
|
+
expect(harness.recoveryActive()).toBe(false);
|
|
4253
|
+
} finally {
|
|
4254
|
+
gate.resolve(testBrokerDiscovery);
|
|
4255
|
+
await harness.dispose();
|
|
4256
|
+
publication.mockRestore();
|
|
4257
|
+
}
|
|
4258
|
+
});
|
|
4259
|
+
|
|
4260
|
+
test("optional rejected broker registration reports diagnostics and retries without blocking turns", async () => {
|
|
4261
|
+
const first = Promise.withResolvers<BrokerDiscovery>();
|
|
4262
|
+
const retry = Promise.withResolvers<BrokerDiscovery>();
|
|
4263
|
+
let attempts = 0;
|
|
4264
|
+
const warning = spyOn(logger, "warn").mockImplementation(() => {});
|
|
4265
|
+
const harness = await brokerRegistrationHarness({
|
|
4266
|
+
ensureBrokerImpl: () => (++attempts === 1 ? first.promise : retry.promise),
|
|
4267
|
+
});
|
|
4268
|
+
try {
|
|
4269
|
+
await harness.emit("session_start");
|
|
4270
|
+
first.reject(Object.assign(new Error("injected broker contention"), { code: "acquire_timeout" }));
|
|
4271
|
+
await Bun.sleep(0);
|
|
4272
|
+
expect(warning).toHaveBeenCalledWith("sdk broker registration unavailable", { code: "acquire_timeout" });
|
|
4273
|
+
// The settled startup attempt armed recovery, and its failure holds the next tick.
|
|
4274
|
+
expect(harness.recoveryActive()).toBe(true);
|
|
4275
|
+
harness.recover();
|
|
4276
|
+
await harness.emit("turn_start");
|
|
4277
|
+
harness.recover();
|
|
4278
|
+
expect(attempts).toBe(2);
|
|
4279
|
+
expect(harness.turnStarts()).toBe(1);
|
|
4280
|
+
expect(harness.stops()).toBe(0);
|
|
4281
|
+
retry.reject(Object.assign(new Error("injected retry failure"), { code: "acquire_timeout" }));
|
|
4282
|
+
await Bun.sleep(0);
|
|
4283
|
+
expect(
|
|
4284
|
+
warning.mock.calls.filter(([message]) => message === "sdk broker registration unavailable"),
|
|
4285
|
+
).toHaveLength(2);
|
|
4286
|
+
} finally {
|
|
4287
|
+
first.resolve(testBrokerDiscovery);
|
|
4288
|
+
retry.resolve(testBrokerDiscovery);
|
|
4289
|
+
await harness.dispose();
|
|
4290
|
+
warning.mockRestore();
|
|
4291
|
+
}
|
|
4292
|
+
});
|
|
4293
|
+
|
|
4294
|
+
test("optional broker publication completing after shutdown is unregistered with its ownership proof", async () => {
|
|
4295
|
+
const published = Promise.withResolvers<SessionIndexEvent>();
|
|
4296
|
+
const release = Promise.withResolvers<void>();
|
|
4297
|
+
const unregistered = Promise.withResolvers<void>();
|
|
4298
|
+
const append = SessionIndex.prototype.append;
|
|
4299
|
+
const unregister = SessionIndex.prototype.unregisterIfCurrent;
|
|
4300
|
+
const publication = spyOn(SessionIndex.prototype, "append").mockImplementation(async function (
|
|
4301
|
+
this: SessionIndex,
|
|
4302
|
+
input,
|
|
4303
|
+
) {
|
|
4304
|
+
const event = await append.call(this, input);
|
|
4305
|
+
if (input.type === "host_registered") {
|
|
4306
|
+
published.resolve(event);
|
|
4307
|
+
await release.promise;
|
|
4308
|
+
}
|
|
4309
|
+
return event;
|
|
4310
|
+
});
|
|
4311
|
+
const cleanup = spyOn(SessionIndex.prototype, "unregisterIfCurrent").mockImplementation(async function (
|
|
4312
|
+
this: SessionIndex,
|
|
4313
|
+
...args
|
|
4314
|
+
) {
|
|
4315
|
+
const result = await unregister.apply(this, args);
|
|
4316
|
+
unregistered.resolve();
|
|
4317
|
+
return result;
|
|
4318
|
+
});
|
|
4319
|
+
const harness = await brokerRegistrationHarness({ ensureBrokerImpl: async () => testBrokerDiscovery });
|
|
4320
|
+
try {
|
|
4321
|
+
await harness.emit("session_start");
|
|
4322
|
+
const ownership = await published.promise;
|
|
4323
|
+
await harness.emit("session_shutdown");
|
|
4324
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
4325
|
+
release.resolve();
|
|
4326
|
+
await unregistered.promise;
|
|
4327
|
+
expect(cleanup).toHaveBeenCalledWith(ownership);
|
|
4328
|
+
expect(harness.recoveryActive()).toBe(false);
|
|
4329
|
+
} finally {
|
|
4330
|
+
release.resolve();
|
|
4331
|
+
await harness.dispose();
|
|
4332
|
+
publication.mockRestore();
|
|
4333
|
+
cleanup.mockRestore();
|
|
4334
|
+
}
|
|
4335
|
+
});
|
|
4336
|
+
|
|
4337
|
+
for (const outcome of ["success", "failure"] as const) {
|
|
4338
|
+
test(`required broker registration blocks startup and turns until ${outcome}`, async () => {
|
|
4339
|
+
const gate = Promise.withResolvers<BrokerDiscovery>();
|
|
4340
|
+
const entered = Promise.withResolvers<void>();
|
|
4341
|
+
let attempts = 0;
|
|
4342
|
+
const harness = await brokerRegistrationHarness({
|
|
4343
|
+
brokerRegistrationRequired: true,
|
|
4344
|
+
lifecycleRequestId: "required-registration-marker",
|
|
4345
|
+
ensureBrokerImpl: () => {
|
|
4346
|
+
attempts += 1;
|
|
4347
|
+
entered.resolve();
|
|
4348
|
+
return gate.promise;
|
|
4349
|
+
},
|
|
4350
|
+
});
|
|
4351
|
+
let startupSettled = false;
|
|
4352
|
+
let turnSettled = false;
|
|
4353
|
+
const startup = harness.emit("session_start").then(
|
|
4354
|
+
() => {
|
|
4355
|
+
startupSettled = true;
|
|
4356
|
+
return undefined;
|
|
4357
|
+
},
|
|
4358
|
+
error => {
|
|
4359
|
+
startupSettled = true;
|
|
4360
|
+
return error;
|
|
4361
|
+
},
|
|
4362
|
+
);
|
|
4363
|
+
await entered.promise;
|
|
4364
|
+
const turn = harness.emit("turn_start").then(
|
|
4365
|
+
() => {
|
|
4366
|
+
turnSettled = true;
|
|
4367
|
+
return undefined;
|
|
4368
|
+
},
|
|
4369
|
+
error => {
|
|
4370
|
+
turnSettled = true;
|
|
4371
|
+
return error;
|
|
4372
|
+
},
|
|
4373
|
+
);
|
|
4374
|
+
try {
|
|
4375
|
+
await Bun.sleep(0);
|
|
4376
|
+
expect(startupSettled).toBe(false);
|
|
4377
|
+
expect(turnSettled).toBe(false);
|
|
4378
|
+
expect(harness.turnStarts()).toBe(0);
|
|
4379
|
+
expect(attempts).toBe(1);
|
|
4380
|
+
expect(harness.recoveryActive()).toBe(false);
|
|
4381
|
+
if (outcome === "failure") {
|
|
4382
|
+
const error = Object.assign(new Error("required broker unavailable"), { code: "acquire_timeout" });
|
|
4383
|
+
gate.reject(error);
|
|
4384
|
+
expect(await startup).toBe(error);
|
|
4385
|
+
expect(await turn).toBe(error);
|
|
4386
|
+
expect(harness.stops()).toBe(1);
|
|
4387
|
+
expect(harness.turnStarts()).toBe(0);
|
|
4388
|
+
expect(harness.recoveryActive()).toBe(false);
|
|
4389
|
+
} else {
|
|
4390
|
+
gate.resolve(testBrokerDiscovery);
|
|
4391
|
+
expect(await startup).toBeUndefined();
|
|
4392
|
+
expect(await turn).toBeUndefined();
|
|
4393
|
+
expect(harness.turnStarts()).toBe(1);
|
|
4394
|
+
expect(harness.recoveryActive()).toBe(true);
|
|
4395
|
+
await harness.emit("turn_start");
|
|
4396
|
+
expect(attempts).toBe(1);
|
|
4397
|
+
}
|
|
4398
|
+
} finally {
|
|
4399
|
+
gate.resolve(testBrokerDiscovery);
|
|
4400
|
+
await Promise.all([startup, turn]);
|
|
4401
|
+
await harness.dispose();
|
|
4402
|
+
}
|
|
4403
|
+
});
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4149
4406
|
test("rejects lifecycle-required SDK-only startup when broker registration fails", async () => {
|
|
4150
4407
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-broker-required-"));
|
|
4151
4408
|
const agentDir = path.join(cwd, ".gjc", "agent");
|
|
@@ -4157,7 +4414,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4157
4414
|
handlers.set(event, handler);
|
|
4158
4415
|
},
|
|
4159
4416
|
} as any;
|
|
4160
|
-
|
|
4417
|
+
createTestRuntimeExtension(api, {
|
|
4161
4418
|
agentDir,
|
|
4162
4419
|
brokerRegistrationRequired: true,
|
|
4163
4420
|
lifecycleRequestId: "broker-required-marker",
|
|
@@ -4180,10 +4437,112 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4180
4437
|
}
|
|
4181
4438
|
});
|
|
4182
4439
|
});
|
|
4440
|
+
|
|
4441
|
+
// Production discovery deliberately launches a daemon that outlives its caller.
|
|
4442
|
+
// Unit fixtures must never launch it: shutting down a host does not own that daemon.
|
|
4443
|
+
function createTestRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {
|
|
4444
|
+
createSdkSessionRuntimeExtension(api, {
|
|
4445
|
+
...options,
|
|
4446
|
+
ensureBrokerImpl: options.ensureBrokerImpl ?? (async () => testBrokerDiscovery),
|
|
4447
|
+
});
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
const testBrokerDiscovery: BrokerDiscovery = {
|
|
4451
|
+
version: 1,
|
|
4452
|
+
protocolVersion: 3,
|
|
4453
|
+
packageGeneration: "test",
|
|
4454
|
+
ownerId: "test-broker",
|
|
4455
|
+
pid: process.pid,
|
|
4456
|
+
incarnation: "test-incarnation",
|
|
4457
|
+
host: "127.0.0.1",
|
|
4458
|
+
port: 1,
|
|
4459
|
+
url: "ws://127.0.0.1:1",
|
|
4460
|
+
token: "test-token",
|
|
4461
|
+
startedAt: 0,
|
|
4462
|
+
heartbeatAt: 0,
|
|
4463
|
+
};
|
|
4464
|
+
|
|
4465
|
+
async function brokerRegistrationHarness(
|
|
4466
|
+
options: Pick<
|
|
4467
|
+
CreateSdkSessionRuntimeOptions,
|
|
4468
|
+
"ensureBrokerImpl" | "brokerRegistrationRequired" | "lifecycleRequestId"
|
|
4469
|
+
>,
|
|
4470
|
+
) {
|
|
4471
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-registration-"));
|
|
4472
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
4473
|
+
const broadcasts: SdkFrame[] = [];
|
|
4474
|
+
let stops = 0;
|
|
4475
|
+
let recovery: (() => void) | undefined;
|
|
4476
|
+
let recoveryActive = false;
|
|
4477
|
+
const timer = { unref() {} } as NodeJS.Timeout;
|
|
4478
|
+
const api = {
|
|
4479
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
4480
|
+
handlers.set(event, handler);
|
|
4481
|
+
},
|
|
4482
|
+
} as unknown as ExtensionAPI;
|
|
4483
|
+
createTestRuntimeExtension(api, {
|
|
4484
|
+
...options,
|
|
4485
|
+
agentDir: path.join(cwd, "agent"),
|
|
4486
|
+
setIntervalImpl: ((callback: () => void) => {
|
|
4487
|
+
recovery = callback;
|
|
4488
|
+
recoveryActive = true;
|
|
4489
|
+
return timer;
|
|
4490
|
+
}) as typeof setInterval,
|
|
4491
|
+
clearIntervalImpl: (() => {
|
|
4492
|
+
recoveryActive = false;
|
|
4493
|
+
}) as typeof clearInterval,
|
|
4494
|
+
createTransport: async ({ sessionId, stateRoot, token }) => ({
|
|
4495
|
+
sessionId,
|
|
4496
|
+
stateRoot,
|
|
4497
|
+
token,
|
|
4498
|
+
onFrame: () => undefined,
|
|
4499
|
+
sendFrame: () => {},
|
|
4500
|
+
broadcastFrame: frame => {
|
|
4501
|
+
broadcasts.push(frame);
|
|
4502
|
+
},
|
|
4503
|
+
start: async () => {
|
|
4504
|
+
const url = "ws://127.0.0.1:1";
|
|
4505
|
+
await Bun.write(
|
|
4506
|
+
path.join(stateRoot, "sdk", `${sessionId}.json`),
|
|
4507
|
+
JSON.stringify({
|
|
4508
|
+
sessionId,
|
|
4509
|
+
token,
|
|
4510
|
+
pid: process.pid,
|
|
4511
|
+
url,
|
|
4512
|
+
}),
|
|
4513
|
+
);
|
|
4514
|
+
return { url };
|
|
4515
|
+
},
|
|
4516
|
+
stop: async () => {
|
|
4517
|
+
stops += 1;
|
|
4518
|
+
},
|
|
4519
|
+
}),
|
|
4520
|
+
});
|
|
4521
|
+
const context = extensionContext("broker-registration", cwd);
|
|
4522
|
+
const emit = async (event: string): Promise<void> => {
|
|
4523
|
+
await handlers.get(event)?.({}, context);
|
|
4524
|
+
};
|
|
4525
|
+
return {
|
|
4526
|
+
emit,
|
|
4527
|
+
recover: () => recovery?.(),
|
|
4528
|
+
recoveryActive: () => recoveryActive,
|
|
4529
|
+
stops: () => stops,
|
|
4530
|
+
turnStarts: () => broadcasts.filter(frame => frame.type === "event" && frame.kind === "turn_start").length,
|
|
4531
|
+
dispose: async () => {
|
|
4532
|
+
await emit("session_shutdown");
|
|
4533
|
+
await Bun.sleep(0);
|
|
4534
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4535
|
+
},
|
|
4536
|
+
};
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4183
4539
|
interface PreflightHooks {
|
|
4540
|
+
onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
|
|
4184
4541
|
onPreflightAccepted?: () => void;
|
|
4185
4542
|
onPreflightAcceptCommit?: () => void | Promise<void>;
|
|
4186
4543
|
expectedSdkRunToken?: string;
|
|
4544
|
+
preflightSignal?: AbortSignal;
|
|
4545
|
+
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
4187
4546
|
}
|
|
4188
4547
|
|
|
4189
4548
|
interface ResponseFrame {
|
|
@@ -4191,11 +4550,22 @@ interface ResponseFrame {
|
|
|
4191
4550
|
ok?: boolean;
|
|
4192
4551
|
code?: string;
|
|
4193
4552
|
error?: { code: string; message: string };
|
|
4194
|
-
result?: {
|
|
4553
|
+
result?: {
|
|
4554
|
+
status?: string;
|
|
4555
|
+
commandId?: string;
|
|
4556
|
+
turnId?: string;
|
|
4557
|
+
error?: { code: string; message: string };
|
|
4558
|
+
};
|
|
4195
4559
|
}
|
|
4196
4560
|
|
|
4197
4561
|
interface InvocationHarness {
|
|
4198
4562
|
control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
4563
|
+
controlAs(
|
|
4564
|
+
connectionId: string,
|
|
4565
|
+
operation: string,
|
|
4566
|
+
input: Record<string, unknown>,
|
|
4567
|
+
idempotencyKey?: string,
|
|
4568
|
+
): Promise<ResponseFrame>;
|
|
4199
4569
|
query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
4200
4570
|
emit(event: string, payload?: unknown): Promise<void>;
|
|
4201
4571
|
switchSession(sessionId: string): Promise<void>;
|
|
@@ -4267,7 +4637,7 @@ async function invocationHarness(
|
|
|
4267
4637
|
hooks.onDurableAttempt,
|
|
4268
4638
|
)
|
|
4269
4639
|
: undefined;
|
|
4270
|
-
|
|
4640
|
+
createTestRuntimeExtension(api, {
|
|
4271
4641
|
agentDir: cwd,
|
|
4272
4642
|
...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
|
|
4273
4643
|
...(interceptorStore || hooks.terminalAbortSeams
|
|
@@ -4333,16 +4703,26 @@ async function invocationHarness(
|
|
|
4333
4703
|
},
|
|
4334
4704
|
};
|
|
4335
4705
|
await handlers.get("session_start")?.({}, ctx);
|
|
4336
|
-
const request = (frame: Record<string, unknown
|
|
4706
|
+
const request = (frame: Record<string, unknown>, connectionId = "client"): Promise<ResponseFrame> => {
|
|
4337
4707
|
const id = `frame-${nextId}`;
|
|
4338
4708
|
nextId += 1;
|
|
4339
4709
|
const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
|
|
4340
4710
|
waiters.set(id, resolve);
|
|
4341
|
-
deliver?.(
|
|
4711
|
+
deliver?.(connectionId, { ...frame, id } as SdkFrame);
|
|
4342
4712
|
return promise;
|
|
4343
4713
|
};
|
|
4344
4714
|
return {
|
|
4345
4715
|
control: (operation, input) => request({ type: "control_request", operation, input }),
|
|
4716
|
+
controlAs: (connectionId, operation, input, idempotencyKey) =>
|
|
4717
|
+
request(
|
|
4718
|
+
{
|
|
4719
|
+
type: "control_request",
|
|
4720
|
+
operation,
|
|
4721
|
+
input,
|
|
4722
|
+
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
|
4723
|
+
},
|
|
4724
|
+
connectionId,
|
|
4725
|
+
),
|
|
4346
4726
|
query: (name, input) => request({ type: "query_request", query: name, input }),
|
|
4347
4727
|
broadcasts,
|
|
4348
4728
|
emit: async (event, payload) => {
|
|
@@ -4392,6 +4772,500 @@ async function invocationHarness(
|
|
|
4392
4772
|
};
|
|
4393
4773
|
}
|
|
4394
4774
|
|
|
4775
|
+
test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => {
|
|
4776
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`));
|
|
4777
|
+
let harness: InvocationHarness | undefined;
|
|
4778
|
+
let promotion: PreflightHooks["onQueuedPromoted"];
|
|
4779
|
+
try {
|
|
4780
|
+
harness = await invocationHarness(`sdk-only-text-followup-${mode}`, cwd, {
|
|
4781
|
+
sendUserMessage: async (content, options) => {
|
|
4782
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4783
|
+
if (options?.deliverAs === "followUp") {
|
|
4784
|
+
expect(content).toBe("queued generic follow-up");
|
|
4785
|
+
options.onDispatchDisposition?.({ startsOwnRun: false });
|
|
4786
|
+
promotion = options.onQueuedPromoted;
|
|
4787
|
+
return;
|
|
4788
|
+
}
|
|
4789
|
+
await neverSettlingPromise();
|
|
4790
|
+
},
|
|
4791
|
+
});
|
|
4792
|
+
const root = await harness.controlAs("connection-root", "turn.prompt", { text: "root text" });
|
|
4793
|
+
expect(root).toMatchObject({ ok: true, result: { accepted: true } });
|
|
4794
|
+
await harness.emit("agent_start");
|
|
4795
|
+
const followUp = await harness.controlAs("connection-followup", "turn.follow_up", {
|
|
4796
|
+
text: "queued generic follow-up",
|
|
4797
|
+
});
|
|
4798
|
+
expect(followUp).toMatchObject({ ok: true, result: { accepted: true } });
|
|
4799
|
+
const correlation = { commandId: followUp.result?.commandId, turnId: followUp.result?.turnId };
|
|
4800
|
+
expect(await harness.query("turn.result", { kind: "prompt", ...correlation })).toMatchObject({
|
|
4801
|
+
result: { status: "accepted" },
|
|
4802
|
+
});
|
|
4803
|
+
expect(promotion).toBeDefined();
|
|
4804
|
+
promotion?.({ startsOwnRun: false, ...(mode === "removed" ? { removed: true } : {}) });
|
|
4805
|
+
if (mode === "natural")
|
|
4806
|
+
await harness.emit("agent_end", {
|
|
4807
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "root complete" }],
|
|
4808
|
+
});
|
|
4809
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject(
|
|
4810
|
+
mode === "natural" ? { status: "terminal_ok" } : { status: "failed", error: { code: "cancelled" } },
|
|
4811
|
+
);
|
|
4812
|
+
await harness.emit("agent_start");
|
|
4813
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "unrelated" }] });
|
|
4814
|
+
expect(
|
|
4815
|
+
harness.broadcasts.filter(
|
|
4816
|
+
frame =>
|
|
4817
|
+
frame.type === "event" &&
|
|
4818
|
+
frame.kind === "agent_end" &&
|
|
4819
|
+
(frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId ===
|
|
4820
|
+
correlation.commandId &&
|
|
4821
|
+
(frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId,
|
|
4822
|
+
),
|
|
4823
|
+
).toHaveLength(1);
|
|
4824
|
+
} finally {
|
|
4825
|
+
await harness?.stop();
|
|
4826
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4827
|
+
}
|
|
4828
|
+
});
|
|
4829
|
+
|
|
4830
|
+
test.each([
|
|
4831
|
+
"accepting",
|
|
4832
|
+
"accepted",
|
|
4833
|
+
] as const)("SDK-only ordinary abort fences a real %s prompt before agent_start", async phase => {
|
|
4834
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-owned-preflight-${phase}-`));
|
|
4835
|
+
const entered = Promise.withResolvers<void>();
|
|
4836
|
+
const release = Promise.withResolvers<void>();
|
|
4837
|
+
const clientRef = `owned-before-start-${phase}`;
|
|
4838
|
+
const manager = SessionManager.inMemory(cwd);
|
|
4839
|
+
const sessionId = manager.getSessionId();
|
|
4840
|
+
const store = createReconciliationStore({ sessionFile: path.join(cwd, "session.jsonl"), sessionId });
|
|
4841
|
+
const originalRename = fsPromises.rename;
|
|
4842
|
+
let held = false;
|
|
4843
|
+
let modelCalls = 0;
|
|
4844
|
+
let rootAbortCalls = 0;
|
|
4845
|
+
let ownedSignal: AbortSignal | undefined;
|
|
4846
|
+
let harness: InvocationHarness | undefined;
|
|
4847
|
+
let session: AgentSession | undefined;
|
|
4848
|
+
let authStorage: AuthStorage | undefined;
|
|
4849
|
+
const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
|
|
4850
|
+
if (phase === "accepting" && !held && String(to) === store.path) {
|
|
4851
|
+
const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
|
|
4852
|
+
if (document.records.some(record => record.clientRef === clientRef && record.status === "accepted")) {
|
|
4853
|
+
held = true;
|
|
4854
|
+
entered.resolve();
|
|
4855
|
+
await release.promise;
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
await originalRename(from, to);
|
|
4859
|
+
});
|
|
4860
|
+
try {
|
|
4861
|
+
authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
|
|
4862
|
+
const mock = createMockModel({
|
|
4863
|
+
responses: [
|
|
4864
|
+
() => {
|
|
4865
|
+
modelCalls++;
|
|
4866
|
+
return { content: ["unexpected model execution"] };
|
|
4867
|
+
},
|
|
4868
|
+
],
|
|
4869
|
+
});
|
|
4870
|
+
authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
|
|
4871
|
+
session = new AgentSession({
|
|
4872
|
+
agent: new Agent({
|
|
4873
|
+
getApiKey: () => "test-key",
|
|
4874
|
+
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
4875
|
+
streamFn: mock.stream,
|
|
4876
|
+
}),
|
|
4877
|
+
sessionManager: manager,
|
|
4878
|
+
settings: Settings.isolated({ "compaction.enabled": false }),
|
|
4879
|
+
modelRegistry: new ModelRegistry(authStorage),
|
|
4880
|
+
});
|
|
4881
|
+
harness = await invocationHarness(sessionId, cwd, {
|
|
4882
|
+
isIdle: () => !session!.isStreaming,
|
|
4883
|
+
abort: () => {
|
|
4884
|
+
rootAbortCalls++;
|
|
4885
|
+
},
|
|
4886
|
+
terminalAbortSeams: { getReconciliationStore: () => store },
|
|
4887
|
+
sendUserMessage: (content, options) => {
|
|
4888
|
+
if (typeof content !== "string") throw new Error("Expected a text-only admission.");
|
|
4889
|
+
const { deliverAs, ...admissionOptions } = options ?? {};
|
|
4890
|
+
if (deliverAs !== undefined) throw new Error("Expected an ordinary prompt admission.");
|
|
4891
|
+
ownedSignal = admissionOptions.preflightSignal;
|
|
4892
|
+
return session!.sendUserMessage(content, {
|
|
4893
|
+
...admissionOptions,
|
|
4894
|
+
onPreflightAcceptCommit: async () => {
|
|
4895
|
+
await admissionOptions.onPreflightAcceptCommit?.();
|
|
4896
|
+
if (phase === "accepted") {
|
|
4897
|
+
entered.resolve();
|
|
4898
|
+
await release.promise;
|
|
4899
|
+
}
|
|
4900
|
+
},
|
|
4901
|
+
});
|
|
4902
|
+
},
|
|
4903
|
+
});
|
|
4904
|
+
session.subscribe(async event => {
|
|
4905
|
+
await harness?.emit(event.type, event);
|
|
4906
|
+
});
|
|
4907
|
+
const prompt = harness.controlAs("owner", "turn.prompt", { text: "cancel before execution", clientRef });
|
|
4908
|
+
await entered.promise;
|
|
4909
|
+
if (phase === "accepted") expect(await prompt).toMatchObject({ ok: true, result: { accepted: true } });
|
|
4910
|
+
expect(ownedSignal?.aborted).toBe(false);
|
|
4911
|
+
expect(modelCalls).toBe(0);
|
|
4912
|
+
expect(await harness.controlAs("owner", "turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } });
|
|
4913
|
+
expect(ownedSignal?.aborted).toBe(true);
|
|
4914
|
+
expect(rootAbortCalls).toBe(0);
|
|
4915
|
+
release.resolve();
|
|
4916
|
+
if (phase === "accepting") expect(await prompt).toMatchObject({ ok: false, error: { code: "busy" } });
|
|
4917
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef })).toMatchObject({
|
|
4918
|
+
status: "failed",
|
|
4919
|
+
});
|
|
4920
|
+
await session.waitForIdle();
|
|
4921
|
+
expect(modelCalls).toBe(0);
|
|
4922
|
+
expect(rootAbortCalls).toBe(0);
|
|
4923
|
+
const document = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
|
|
4924
|
+
const durable = document.records.find(record => record.clientRef === clientRef);
|
|
4925
|
+
expect(durable?.status).toBe("failed");
|
|
4926
|
+
expect(durable?.terminalAt).toBeNumber();
|
|
4927
|
+
} finally {
|
|
4928
|
+
release.resolve();
|
|
4929
|
+
await session?.waitForIdle().catch(() => undefined);
|
|
4930
|
+
await harness?.stop();
|
|
4931
|
+
await session?.dispose();
|
|
4932
|
+
authStorage?.close();
|
|
4933
|
+
renameSpy.mockRestore();
|
|
4934
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4935
|
+
}
|
|
4936
|
+
}, 30_000);
|
|
4937
|
+
|
|
4938
|
+
test("SDK-only ordinary abort preserves real foreign and later admissions behind a cancelled durable preflight", async () => {
|
|
4939
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-real-admission-snapshot-"));
|
|
4940
|
+
const entered = Promise.withResolvers<void>();
|
|
4941
|
+
const release = Promise.withResolvers<void>();
|
|
4942
|
+
const foreignStarted = Promise.withResolvers<void>();
|
|
4943
|
+
const releaseForeign = Promise.withResolvers<void>();
|
|
4944
|
+
const manager = SessionManager.inMemory(cwd);
|
|
4945
|
+
const sessionId = manager.getSessionId();
|
|
4946
|
+
const store = createReconciliationStore({ sessionFile: path.join(cwd, "session.jsonl"), sessionId });
|
|
4947
|
+
const originalRename = fsPromises.rename;
|
|
4948
|
+
const signals = new Map<string, AbortSignal | undefined>();
|
|
4949
|
+
const modelCalls: string[] = [];
|
|
4950
|
+
let held = false;
|
|
4951
|
+
let rootAbortCalls = 0;
|
|
4952
|
+
let harness: InvocationHarness | undefined;
|
|
4953
|
+
let session: AgentSession | undefined;
|
|
4954
|
+
let authStorage: AuthStorage | undefined;
|
|
4955
|
+
const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
|
|
4956
|
+
if (!held && String(to) === store.path) {
|
|
4957
|
+
const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
|
|
4958
|
+
if (
|
|
4959
|
+
document.records.some(record => record.clientRef === "cancel-owned-real" && record.status === "accepted")
|
|
4960
|
+
) {
|
|
4961
|
+
held = true;
|
|
4962
|
+
entered.resolve();
|
|
4963
|
+
await release.promise;
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
await originalRename(from, to);
|
|
4967
|
+
});
|
|
4968
|
+
try {
|
|
4969
|
+
authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
|
|
4970
|
+
const mock = createMockModel({
|
|
4971
|
+
responses: [
|
|
4972
|
+
() => {
|
|
4973
|
+
modelCalls.push("seed");
|
|
4974
|
+
return { content: ["natural seed completed"] };
|
|
4975
|
+
},
|
|
4976
|
+
async () => {
|
|
4977
|
+
modelCalls.push("foreign");
|
|
4978
|
+
foreignStarted.resolve();
|
|
4979
|
+
await releaseForeign.promise;
|
|
4980
|
+
return { content: ["foreign admission completed"] };
|
|
4981
|
+
},
|
|
4982
|
+
() => {
|
|
4983
|
+
modelCalls.push("later-owner");
|
|
4984
|
+
return { content: ["later owner admission completed"] };
|
|
4985
|
+
},
|
|
4986
|
+
],
|
|
4987
|
+
});
|
|
4988
|
+
authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
|
|
4989
|
+
session = new AgentSession({
|
|
4990
|
+
agent: new Agent({
|
|
4991
|
+
getApiKey: () => "test-key",
|
|
4992
|
+
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
4993
|
+
streamFn: mock.stream,
|
|
4994
|
+
}),
|
|
4995
|
+
sessionManager: manager,
|
|
4996
|
+
settings: Settings.isolated({ "compaction.enabled": false }),
|
|
4997
|
+
modelRegistry: new ModelRegistry(authStorage),
|
|
4998
|
+
});
|
|
4999
|
+
harness = await invocationHarness(sessionId, cwd, {
|
|
5000
|
+
isIdle: () => !session!.isStreaming,
|
|
5001
|
+
abort: () => {
|
|
5002
|
+
rootAbortCalls++;
|
|
5003
|
+
},
|
|
5004
|
+
terminalAbortSeams: { getReconciliationStore: () => store },
|
|
5005
|
+
sendUserMessage: (content, options) => {
|
|
5006
|
+
if (typeof content !== "string") throw new Error("Expected text-only real admission.");
|
|
5007
|
+
signals.set(content, options?.preflightSignal);
|
|
5008
|
+
const { deliverAs, ...admissionOptions } = options ?? {};
|
|
5009
|
+
if (deliverAs !== undefined && deliverAs !== "steer" && deliverAs !== "followUp")
|
|
5010
|
+
throw new Error("Unsupported real admission delivery mode.");
|
|
5011
|
+
return session!.sendUserMessage(content, {
|
|
5012
|
+
...admissionOptions,
|
|
5013
|
+
...(deliverAs === undefined ? {} : { deliverAs }),
|
|
5014
|
+
});
|
|
5015
|
+
},
|
|
5016
|
+
});
|
|
5017
|
+
session.subscribe(async event => {
|
|
5018
|
+
await harness?.emit(event.type, event);
|
|
5019
|
+
});
|
|
5020
|
+
expect(
|
|
5021
|
+
await harness.controlAs("seed-owner", "turn.prompt", { text: "seed-real", clientRef: "seed-real" }),
|
|
5022
|
+
).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5023
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "seed-real" })).toMatchObject({
|
|
5024
|
+
status: "terminal_ok",
|
|
5025
|
+
});
|
|
5026
|
+
await session.waitForIdle();
|
|
5027
|
+
const owned = harness.controlAs("owner", "turn.prompt", {
|
|
5028
|
+
text: "cancel-owned-real",
|
|
5029
|
+
clientRef: "cancel-owned-real",
|
|
5030
|
+
});
|
|
5031
|
+
await entered.promise;
|
|
5032
|
+
expect(modelCalls).toEqual(["seed"]);
|
|
5033
|
+
expect(signals.get("cancel-owned-real")?.aborted).toBe(false);
|
|
5034
|
+
const foreign = harness.controlAs("foreign", "turn.prompt", { text: "foreign-real", clientRef: "foreign-real" });
|
|
5035
|
+
expect(await harness.controlAs("owner", "turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } });
|
|
5036
|
+
expect(signals.get("cancel-owned-real")?.aborted).toBe(true);
|
|
5037
|
+
expect(rootAbortCalls).toBe(0);
|
|
5038
|
+
release.resolve();
|
|
5039
|
+
expect(await owned).toMatchObject({ ok: false, error: { code: "busy" } });
|
|
5040
|
+
expect(
|
|
5041
|
+
await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "cancel-owned-real" }),
|
|
5042
|
+
).toMatchObject({ status: "failed" });
|
|
5043
|
+
expect(await foreign).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5044
|
+
await foreignStarted.promise;
|
|
5045
|
+
const later = harness.controlAs("owner", "turn.prompt", {
|
|
5046
|
+
text: "later-owner-real",
|
|
5047
|
+
clientRef: "later-owner-real",
|
|
5048
|
+
});
|
|
5049
|
+
expect(await later).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5050
|
+
expect(signals.get("foreign-real")?.aborted).toBe(false);
|
|
5051
|
+
expect(signals.get("later-owner-real")?.aborted).toBe(false);
|
|
5052
|
+
expect(modelCalls).toEqual(["seed", "foreign"]);
|
|
5053
|
+
const pendingDocument = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
|
|
5054
|
+
const pendingLater = pendingDocument.records.find(record => record.clientRef === "later-owner-real");
|
|
5055
|
+
expect(pendingLater?.status).toBe("accepted");
|
|
5056
|
+
expect(pendingLater?.startedAt).toBeUndefined();
|
|
5057
|
+
expect(pendingLater?.terminalAt).toBeUndefined();
|
|
5058
|
+
releaseForeign.resolve();
|
|
5059
|
+
await session.waitForIdle();
|
|
5060
|
+
expect(modelCalls).toEqual(["seed", "foreign", "later-owner"]);
|
|
5061
|
+
expect(rootAbortCalls).toBe(0);
|
|
5062
|
+
for (const clientRef of ["foreign-real", "later-owner-real"]) {
|
|
5063
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef })).toMatchObject({
|
|
5064
|
+
status: "terminal_ok",
|
|
5065
|
+
});
|
|
5066
|
+
}
|
|
5067
|
+
const document = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
|
|
5068
|
+
for (const [clientRef, status] of [
|
|
5069
|
+
["cancel-owned-real", "failed"],
|
|
5070
|
+
["foreign-real", "terminal_ok"],
|
|
5071
|
+
["later-owner-real", "terminal_ok"],
|
|
5072
|
+
] as const) {
|
|
5073
|
+
const record = document.records.find(candidate => candidate.clientRef === clientRef);
|
|
5074
|
+
expect(record?.status).toBe(status);
|
|
5075
|
+
expect(record?.terminalAt).toBeNumber();
|
|
5076
|
+
}
|
|
5077
|
+
} finally {
|
|
5078
|
+
release.resolve();
|
|
5079
|
+
releaseForeign.resolve();
|
|
5080
|
+
await session?.waitForIdle().catch(() => undefined);
|
|
5081
|
+
await harness?.stop();
|
|
5082
|
+
await session?.dispose();
|
|
5083
|
+
authStorage?.close();
|
|
5084
|
+
renameSpy.mockRestore();
|
|
5085
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5086
|
+
}
|
|
5087
|
+
}, 30_000);
|
|
5088
|
+
|
|
5089
|
+
test.each([
|
|
5090
|
+
"ordinary",
|
|
5091
|
+
"terminal",
|
|
5092
|
+
] as const)("SDK-only %s abort cancels only its generic queued owner behind a real active AgentSession run", async mode => {
|
|
5093
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-queued-text-${mode}-`));
|
|
5094
|
+
const rootGate = Promise.withResolvers<void>();
|
|
5095
|
+
const rootStarted = Promise.withResolvers<void>();
|
|
5096
|
+
const failedRemovalWrite = Promise.withResolvers<void>();
|
|
5097
|
+
const modelCalls: number[] = [];
|
|
5098
|
+
let rootAbortCalls = 0;
|
|
5099
|
+
let queuedPreflightSignal: AbortSignal | undefined;
|
|
5100
|
+
let harness: InvocationHarness | undefined;
|
|
5101
|
+
let session: AgentSession | undefined;
|
|
5102
|
+
let authStorage: AuthStorage | undefined;
|
|
5103
|
+
try {
|
|
5104
|
+
authStorage = await AuthStorage.create(path.join(cwd, "testauth.db"));
|
|
5105
|
+
const mock = createMockModel({
|
|
5106
|
+
responses: [
|
|
5107
|
+
async () => {
|
|
5108
|
+
modelCalls.push(Date.now());
|
|
5109
|
+
rootStarted.resolve();
|
|
5110
|
+
await rootGate.promise;
|
|
5111
|
+
return { content: ["root completed"] };
|
|
5112
|
+
},
|
|
5113
|
+
() => {
|
|
5114
|
+
modelCalls.push(Date.now());
|
|
5115
|
+
return { content: ["unrelated follow-up completed"] };
|
|
5116
|
+
},
|
|
5117
|
+
],
|
|
5118
|
+
});
|
|
5119
|
+
authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
|
|
5120
|
+
const modelRegistry = new ModelRegistry(authStorage);
|
|
5121
|
+
const agent = new Agent({
|
|
5122
|
+
getApiKey: () => "test-key",
|
|
5123
|
+
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
5124
|
+
streamFn: mock.stream,
|
|
5125
|
+
});
|
|
5126
|
+
session = new AgentSession({
|
|
5127
|
+
agent,
|
|
5128
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
5129
|
+
settings: Settings.isolated({ "compaction.enabled": false }),
|
|
5130
|
+
modelRegistry,
|
|
5131
|
+
});
|
|
5132
|
+
const hooks: Parameters<typeof invocationHarness>[2] = {
|
|
5133
|
+
isIdle: () => !session!.isStreaming,
|
|
5134
|
+
abort: () => {
|
|
5135
|
+
rootAbortCalls += 1;
|
|
5136
|
+
},
|
|
5137
|
+
sendUserMessage: (content, options) => {
|
|
5138
|
+
if (content === "cancel only this queued text") queuedPreflightSignal = options?.preflightSignal;
|
|
5139
|
+
return session!.sendUserMessage(content as never, options as never);
|
|
5140
|
+
},
|
|
5141
|
+
...(mode === "ordinary"
|
|
5142
|
+
? {
|
|
5143
|
+
persistInterceptor: transition => {
|
|
5144
|
+
if (transition.type === "agent_failed") failedRemovalWrite.resolve();
|
|
5145
|
+
},
|
|
5146
|
+
agentFailedWriteFailures: 1,
|
|
5147
|
+
onDurableAttempt: attempt => {
|
|
5148
|
+
if (attempt.type === "agent_failed" && attempt.outcome === "rejected")
|
|
5149
|
+
failedRemovalWrite.resolve();
|
|
5150
|
+
},
|
|
5151
|
+
}
|
|
5152
|
+
: {
|
|
5153
|
+
terminalAbortSeams: {
|
|
5154
|
+
getTerminalTurnEpoch: () => (session?.isStreaming ? 17 : undefined),
|
|
5155
|
+
getActivePromptHandle: () => (session?.isStreaming ? "root-run-handle" : undefined),
|
|
5156
|
+
getActivePromptOwnerConnectionId: () => (session?.isStreaming ? "connection-root" : undefined),
|
|
5157
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
5158
|
+
abortPromptAndWaitWithTerminal: async () => {
|
|
5159
|
+
rootAbortCalls += 1;
|
|
5160
|
+
return { status: "settled", terminalScope: {} };
|
|
5161
|
+
},
|
|
5162
|
+
},
|
|
5163
|
+
}),
|
|
5164
|
+
};
|
|
5165
|
+
harness = await invocationHarness(`queued-text-${mode}`, cwd, hooks);
|
|
5166
|
+
session.subscribe(async event => {
|
|
5167
|
+
await harness?.emit(event.type, event);
|
|
5168
|
+
});
|
|
5169
|
+
|
|
5170
|
+
const root = await harness.controlAs("connection-root", "turn.prompt", { text: "root owned by B" });
|
|
5171
|
+
expect(root).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5172
|
+
await rootStarted.promise;
|
|
5173
|
+
const unrelated = await harness.controlAs("connection-other", "turn.prompt", {
|
|
5174
|
+
text: "keep unrelated queued text",
|
|
5175
|
+
});
|
|
5176
|
+
expect(unrelated).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5177
|
+
const queued = await harness.controlAs("connection-requester", "turn.prompt", {
|
|
5178
|
+
text: "cancel only this queued text",
|
|
5179
|
+
});
|
|
5180
|
+
expect(queued).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5181
|
+
const correlation = { commandId: queued.result?.commandId, turnId: queued.result?.turnId };
|
|
5182
|
+
expect(queuedPreflightSignal).toBeInstanceOf(AbortSignal);
|
|
5183
|
+
expect(queuedPreflightSignal?.aborted).toBe(false);
|
|
5184
|
+
expect(session.getQueuedMessages().steering).toEqual([
|
|
5185
|
+
"keep unrelated queued text",
|
|
5186
|
+
"cancel only this queued text",
|
|
5187
|
+
]);
|
|
5188
|
+
|
|
5189
|
+
let abortCompleted = false;
|
|
5190
|
+
const abortRequest =
|
|
5191
|
+
mode === "ordinary"
|
|
5192
|
+
? harness.controlAs("connection-requester", "turn.abort", { mode: "turn" })
|
|
5193
|
+
: harness.controlAs("connection-requester", "turn.abort", { mode: "terminal" }, "queued-text-terminal");
|
|
5194
|
+
const observedAbort = abortRequest.then(response => {
|
|
5195
|
+
abortCompleted = true;
|
|
5196
|
+
return response;
|
|
5197
|
+
});
|
|
5198
|
+
if (mode === "ordinary") {
|
|
5199
|
+
await failedRemovalWrite.promise;
|
|
5200
|
+
await Bun.sleep(0);
|
|
5201
|
+
expect(abortCompleted).toBe(false);
|
|
5202
|
+
}
|
|
5203
|
+
const abortResponse = await observedAbort;
|
|
5204
|
+
if (mode === "ordinary") expect(abortResponse).toMatchObject({ ok: true, result: { aborted: true } });
|
|
5205
|
+
else
|
|
5206
|
+
expect(abortResponse).toMatchObject({
|
|
5207
|
+
ok: true,
|
|
5208
|
+
result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
|
|
5209
|
+
});
|
|
5210
|
+
expect(queuedPreflightSignal?.aborted).toBe(true);
|
|
5211
|
+
expect(rootAbortCalls).toBe(0);
|
|
5212
|
+
expect(session.isStreaming).toBe(true);
|
|
5213
|
+
expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queued text"]);
|
|
5214
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
|
|
5215
|
+
status: "failed",
|
|
5216
|
+
error: { code: "cancelled" },
|
|
5217
|
+
});
|
|
5218
|
+
const repeatedAbort = await harness.controlAs(
|
|
5219
|
+
"connection-requester",
|
|
5220
|
+
"turn.abort",
|
|
5221
|
+
mode === "ordinary" ? { mode: "turn" } : { mode: "terminal" },
|
|
5222
|
+
mode === "terminal" ? "queued-text-terminal-retry" : undefined,
|
|
5223
|
+
);
|
|
5224
|
+
if (mode === "ordinary")
|
|
5225
|
+
expect(repeatedAbort).toMatchObject({ ok: true, result: { aborted: false, turn: "no_active_turn" } });
|
|
5226
|
+
else
|
|
5227
|
+
expect(repeatedAbort).toMatchObject({
|
|
5228
|
+
ok: true,
|
|
5229
|
+
result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
|
|
5230
|
+
});
|
|
5231
|
+
expect(rootAbortCalls).toBe(0);
|
|
5232
|
+
expect(session.isStreaming).toBe(true);
|
|
5233
|
+
expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queued text"]);
|
|
5234
|
+
|
|
5235
|
+
rootGate.resolve();
|
|
5236
|
+
await session.waitForIdle();
|
|
5237
|
+
const userMessages = session.agent.state.messages.filter(
|
|
5238
|
+
(message): message is UserMessage => message.role === "user",
|
|
5239
|
+
);
|
|
5240
|
+
const userText = (message: (typeof userMessages)[number]): string =>
|
|
5241
|
+
typeof message.content === "string"
|
|
5242
|
+
? message.content
|
|
5243
|
+
: message.content
|
|
5244
|
+
.filter(block => block.type === "text")
|
|
5245
|
+
.map(block => block.text)
|
|
5246
|
+
.join("");
|
|
5247
|
+
expect(userMessages.map(userText)).toEqual(["root owned by B", "keep unrelated queued text"]);
|
|
5248
|
+
expect(modelCalls).toHaveLength(2);
|
|
5249
|
+
expect(
|
|
5250
|
+
harness.broadcasts.filter(
|
|
5251
|
+
frame =>
|
|
5252
|
+
frame.type === "event" &&
|
|
5253
|
+
frame.kind === "agent_end" &&
|
|
5254
|
+
(frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId ===
|
|
5255
|
+
correlation.commandId &&
|
|
5256
|
+
(frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId,
|
|
5257
|
+
),
|
|
5258
|
+
).toHaveLength(1);
|
|
5259
|
+
} finally {
|
|
5260
|
+
rootGate.resolve();
|
|
5261
|
+
await session?.waitForIdle().catch(() => undefined);
|
|
5262
|
+
await harness?.stop();
|
|
5263
|
+
await session?.dispose();
|
|
5264
|
+
authStorage?.close();
|
|
5265
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5266
|
+
}
|
|
5267
|
+
});
|
|
5268
|
+
|
|
4395
5269
|
/** Reconciliation store used to inject durable-write failures from tests: wraps a
|
|
4396
5270
|
* session-file-backed store and fails the write whenever the staged records contain
|
|
4397
5271
|
* the intermediate failed-without-terminal state an agent_failed transition persists. */
|
|
@@ -4524,14 +5398,420 @@ function neverSettlingPromise(): Promise<void> {
|
|
|
4524
5398
|
return Promise.withResolvers<void>().promise;
|
|
4525
5399
|
}
|
|
4526
5400
|
|
|
4527
|
-
|
|
4528
|
-
const
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
5401
|
+
function typedContextOverflowStream(model: Model, onEmitted?: () => void): AssistantMessageEventStream {
|
|
5402
|
+
const stream = new AssistantMessageEventStream();
|
|
5403
|
+
queueMicrotask(() => {
|
|
5404
|
+
const message: AssistantMessage & {
|
|
5405
|
+
transportFailure: { kind: "transport"; status: number; openaiErrorCode: string };
|
|
5406
|
+
} = {
|
|
5407
|
+
role: "assistant",
|
|
5408
|
+
content: [],
|
|
5409
|
+
api: model.api,
|
|
5410
|
+
provider: model.provider,
|
|
5411
|
+
model: model.id,
|
|
5412
|
+
usage: {
|
|
5413
|
+
input: 0,
|
|
5414
|
+
output: 0,
|
|
5415
|
+
cacheRead: 0,
|
|
5416
|
+
cacheWrite: 0,
|
|
5417
|
+
totalTokens: 0,
|
|
5418
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
5419
|
+
},
|
|
5420
|
+
stopReason: "error",
|
|
5421
|
+
errorMessage: "",
|
|
5422
|
+
errorStatus: 400,
|
|
5423
|
+
timestamp: Date.now(),
|
|
5424
|
+
transportFailure: { kind: "transport", status: 400, openaiErrorCode: "context_length_exceeded" },
|
|
5425
|
+
};
|
|
5426
|
+
stream.push({ type: "start", partial: message });
|
|
5427
|
+
stream.push({ type: "error", reason: "error", error: message });
|
|
5428
|
+
onEmitted?.();
|
|
5429
|
+
});
|
|
5430
|
+
return stream;
|
|
5431
|
+
}
|
|
5432
|
+
|
|
5433
|
+
function selector(model: Model): string {
|
|
5434
|
+
return `${model.provider}/${model.id}`;
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5437
|
+
async function createTerminalizationSession(
|
|
5438
|
+
cwd: string,
|
|
5439
|
+
streamFn: AgentOptions["streamFn"],
|
|
5440
|
+
settingsOverrides: Record<string, unknown> = {},
|
|
5441
|
+
withExtensions = false,
|
|
5442
|
+
): Promise<{ session: AgentSession; authStorage: AuthStorage; model: Model }> {
|
|
5443
|
+
const authStorage = await AuthStorage.create(path.join(cwd, "testauth.db"));
|
|
5444
|
+
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
5445
|
+
const fallback = getBundledModel("openai", "gpt-4o-mini");
|
|
5446
|
+
if (!model || !fallback) throw new Error("Expected bundled test models");
|
|
5447
|
+
authStorage.setRuntimeApiKey(model.provider, "test-key");
|
|
5448
|
+
authStorage.setRuntimeApiKey(fallback.provider, "test-key");
|
|
5449
|
+
const settings = Settings.isolated({
|
|
5450
|
+
"compaction.enabled": false,
|
|
5451
|
+
"contextPromotion.enabled": false,
|
|
5452
|
+
"fallback.maxAttempts": 3,
|
|
5453
|
+
"retry.baseDelayMs": 1,
|
|
5454
|
+
...settingsOverrides,
|
|
5455
|
+
});
|
|
5456
|
+
settings.setModelRole("default", selector(model));
|
|
5457
|
+
const agent = new Agent({
|
|
5458
|
+
getApiKey: provider => `${provider}-key`,
|
|
5459
|
+
initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
5460
|
+
streamFn,
|
|
5461
|
+
});
|
|
5462
|
+
const sessionManager = SessionManager.inMemory(cwd);
|
|
5463
|
+
const modelRegistry = new ModelRegistry(authStorage);
|
|
5464
|
+
const session = new AgentSession({
|
|
5465
|
+
agent,
|
|
5466
|
+
sessionManager,
|
|
5467
|
+
settings,
|
|
5468
|
+
modelRegistry,
|
|
5469
|
+
extensionRunner: withExtensions
|
|
5470
|
+
? new ExtensionRunner(
|
|
5471
|
+
[],
|
|
5472
|
+
{ flagValues: new Map(), pendingProviderRegistrations: [] } as never,
|
|
5473
|
+
cwd,
|
|
5474
|
+
sessionManager,
|
|
5475
|
+
modelRegistry,
|
|
5476
|
+
undefined,
|
|
5477
|
+
settings,
|
|
5478
|
+
)
|
|
5479
|
+
: undefined,
|
|
5480
|
+
});
|
|
5481
|
+
session.setConfiguredModelChain("default", [selector(model), selector(fallback)], "test");
|
|
5482
|
+
return { session, authStorage, model };
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5485
|
+
function deferredRealSendUserMessage(session: AgentSession) {
|
|
5486
|
+
return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
|
|
5487
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5488
|
+
const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
|
|
5489
|
+
void session.sendUserMessage(content as string, dispatchOptions as never);
|
|
5490
|
+
await neverSettlingPromise();
|
|
5491
|
+
};
|
|
5492
|
+
}
|
|
5493
|
+
|
|
5494
|
+
function realSendUserMessage(session: AgentSession) {
|
|
5495
|
+
return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
|
|
5496
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5497
|
+
const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
|
|
5498
|
+
await session.sendUserMessage(content as string, dispatchOptions as never);
|
|
5499
|
+
};
|
|
5500
|
+
}
|
|
5501
|
+
|
|
5502
|
+
test.each([
|
|
5503
|
+
"clearContext",
|
|
5504
|
+
"newSession",
|
|
5505
|
+
"compact",
|
|
5506
|
+
] as const)("SDK %s disconnect cancels only its accepted publication owner before disposal", async operation => {
|
|
5507
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-${operation}-publication-`));
|
|
5508
|
+
const entered = Promise.withResolvers<void>();
|
|
5509
|
+
const freshEntered = Promise.withResolvers<void>();
|
|
5510
|
+
const aborted = Promise.withResolvers<void>();
|
|
5511
|
+
const release = Promise.withResolvers<void>();
|
|
5512
|
+
let session: AgentSession | undefined;
|
|
5513
|
+
let authStorage: AuthStorage | undefined;
|
|
5514
|
+
let harness: InvocationHarness | undefined;
|
|
5515
|
+
let submission: Promise<void> | undefined;
|
|
5516
|
+
let providerCalls = 0;
|
|
5517
|
+
try {
|
|
5518
|
+
const real = await createTerminalizationSession(cwd, (model, context, options) => {
|
|
5519
|
+
providerCalls++;
|
|
5520
|
+
if (providerCalls === 2) freshEntered.resolve();
|
|
5521
|
+
if (providerCalls === 1)
|
|
5522
|
+
options?.signal?.addEventListener(
|
|
5523
|
+
"abort",
|
|
5524
|
+
() => {
|
|
5525
|
+
if (operation === "compact") session?.abortCompaction();
|
|
5526
|
+
aborted.resolve();
|
|
5527
|
+
},
|
|
5528
|
+
{ once: true },
|
|
5529
|
+
);
|
|
5530
|
+
return createMockModel({
|
|
5531
|
+
responses: [
|
|
5532
|
+
async () => {
|
|
5533
|
+
if (providerCalls === 1) {
|
|
5534
|
+
entered.resolve();
|
|
5535
|
+
await release.promise;
|
|
5536
|
+
}
|
|
5537
|
+
return { content: ["fresh independent completion"] };
|
|
5538
|
+
},
|
|
5539
|
+
],
|
|
5540
|
+
}).stream(model, context, options);
|
|
5541
|
+
});
|
|
5542
|
+
session = real.session;
|
|
5543
|
+
authStorage = real.authStorage;
|
|
5544
|
+
const send = realSendUserMessage(session);
|
|
5545
|
+
harness = await invocationHarness(`disconnect-publication-${operation}`, cwd, {
|
|
5546
|
+
isIdle: () => !session?.isStreaming,
|
|
5547
|
+
sendUserMessage: (content, options) => {
|
|
5548
|
+
submission = send(content, options);
|
|
5549
|
+
void submission.catch(() => undefined);
|
|
5550
|
+
return submission;
|
|
5551
|
+
},
|
|
5552
|
+
});
|
|
5553
|
+
session.subscribe(event => harness?.emit(event.type, event));
|
|
5554
|
+
expect((await harness.control("turn.prompt", { text: "cancel this disconnected owner" })).ok).toBe(true);
|
|
5555
|
+
await entered.promise;
|
|
5556
|
+
const transition = operation === "compact" ? session.compact() : session[operation]();
|
|
5557
|
+
void transition.catch(() => undefined);
|
|
5558
|
+
await aborted.promise;
|
|
5559
|
+
release.resolve();
|
|
5560
|
+
if (operation === "compact") await expect(transition).rejects.toBeInstanceOf(CompactionCancelledError);
|
|
5561
|
+
else expect(await transition).toBe(true);
|
|
5562
|
+
if (!submission) throw new Error("Expected the accepted actual SDK submission promise.");
|
|
5563
|
+
await expect(submission).rejects.toMatchObject({ code: "cancelled" });
|
|
5564
|
+
const fresh = await harness.control("turn.prompt", { text: "run a fresh independent prompt" });
|
|
5565
|
+
expect(fresh.ok).toBe(true);
|
|
5566
|
+
await freshEntered.promise;
|
|
5567
|
+
await expect(submission).resolves.toBeUndefined();
|
|
5568
|
+
expect(
|
|
5569
|
+
await settledStatus(harness, "turn.result", {
|
|
5570
|
+
kind: "prompt",
|
|
5571
|
+
commandId: fresh.result?.commandId,
|
|
5572
|
+
turnId: fresh.result?.turnId,
|
|
5573
|
+
}),
|
|
5574
|
+
).toMatchObject({ status: "terminal_ok" });
|
|
5575
|
+
expect(session.agent.state.messages.at(-1)).toMatchObject({
|
|
5576
|
+
role: "assistant",
|
|
5577
|
+
content: [{ type: "text", text: "fresh independent completion" }],
|
|
5578
|
+
});
|
|
5579
|
+
expect(providerCalls).toBe(2);
|
|
5580
|
+
} finally {
|
|
5581
|
+
release.resolve();
|
|
5582
|
+
await session?.dispose();
|
|
5583
|
+
authStorage?.close();
|
|
5584
|
+
await harness?.stop();
|
|
5585
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5586
|
+
}
|
|
5587
|
+
});
|
|
5588
|
+
|
|
5589
|
+
test("SDK disconnect preserves an already published projected terminal's actual submission", async () => {
|
|
5590
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-publishing-disconnect-"));
|
|
5591
|
+
const delivered = Promise.withResolvers<void>();
|
|
5592
|
+
const release = Promise.withResolvers<void>();
|
|
5593
|
+
let session: AgentSession | undefined;
|
|
5594
|
+
let authStorage: AuthStorage | undefined;
|
|
5595
|
+
let harness: InvocationHarness | undefined;
|
|
5596
|
+
let submission: Promise<void> | undefined;
|
|
5597
|
+
let restoreEmission: (() => void) | undefined;
|
|
5598
|
+
let terminalCount = 0;
|
|
5599
|
+
try {
|
|
5600
|
+
const real = await createTerminalizationSession(
|
|
5601
|
+
cwd,
|
|
5602
|
+
(model, context, options) =>
|
|
5603
|
+
createMockModel({ responses: [{ content: ["projected completion"] }] }).stream(model, context, options),
|
|
5604
|
+
{},
|
|
5605
|
+
true,
|
|
5606
|
+
);
|
|
5607
|
+
session = real.session;
|
|
5608
|
+
authStorage = real.authStorage;
|
|
5609
|
+
const send = realSendUserMessage(session);
|
|
5610
|
+
harness = await invocationHarness("publishing-disconnect", cwd, {
|
|
5611
|
+
isIdle: () => !session?.isStreaming,
|
|
5612
|
+
sendUserMessage: (content, options) => {
|
|
5613
|
+
submission = send(content, options);
|
|
5614
|
+
void submission.catch(() => undefined);
|
|
5615
|
+
return submission;
|
|
5616
|
+
},
|
|
5617
|
+
});
|
|
5618
|
+
session.subscribe(event => (event.type === "agent_end" ? undefined : harness?.emit(event.type, event)));
|
|
5619
|
+
const runner = session.extensionRunner;
|
|
5620
|
+
if (!runner) throw new Error("Expected the real projected terminal extension bridge.");
|
|
5621
|
+
const emit = runner.emit.bind(runner);
|
|
5622
|
+
const emission = spyOn(runner, "emit").mockImplementation(async event => {
|
|
5623
|
+
const result = await emit(event);
|
|
5624
|
+
if (event.type === "agent_end") {
|
|
5625
|
+
terminalCount++;
|
|
5626
|
+
await harness?.emit(event.type, event);
|
|
5627
|
+
delivered.resolve();
|
|
5628
|
+
await release.promise;
|
|
5629
|
+
}
|
|
5630
|
+
return result;
|
|
5631
|
+
});
|
|
5632
|
+
restoreEmission = () => emission.mockRestore();
|
|
5633
|
+
const accepted = await harness.control("turn.prompt", { text: "publish then disconnect this owner" });
|
|
5634
|
+
expect(accepted.ok).toBe(true);
|
|
5635
|
+
await delivered.promise;
|
|
5636
|
+
expect(
|
|
5637
|
+
await settledStatus(harness, "turn.result", {
|
|
5638
|
+
kind: "prompt",
|
|
5639
|
+
commandId: accepted.result?.commandId,
|
|
5640
|
+
turnId: accepted.result?.turnId,
|
|
5641
|
+
}),
|
|
5642
|
+
).toMatchObject({ status: "terminal_ok", content: { text: "projected completion" } });
|
|
5643
|
+
const transition = session.clearContext();
|
|
5644
|
+
release.resolve();
|
|
5645
|
+
expect(await transition).toBe(true);
|
|
5646
|
+
if (!submission) throw new Error("Expected the actual publishing SDK submission promise.");
|
|
5647
|
+
await expect(submission).resolves.toBeUndefined();
|
|
5648
|
+
expect(terminalCount).toBe(1);
|
|
5649
|
+
} finally {
|
|
5650
|
+
release.resolve();
|
|
5651
|
+
restoreEmission?.();
|
|
5652
|
+
await session?.dispose();
|
|
5653
|
+
authStorage?.close();
|
|
5654
|
+
await harness?.stop();
|
|
5655
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5656
|
+
}
|
|
5657
|
+
});
|
|
5658
|
+
|
|
5659
|
+
test("SDK disposal rejects an accepted real submission whose terminal bridge is disconnected", async () => {
|
|
5660
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-dispose-publication-"));
|
|
5661
|
+
const entered = Promise.withResolvers<void>();
|
|
5662
|
+
const release = Promise.withResolvers<void>();
|
|
5663
|
+
let session: AgentSession | undefined;
|
|
5664
|
+
let authStorage: AuthStorage | undefined;
|
|
5665
|
+
let harness: InvocationHarness | undefined;
|
|
5666
|
+
let submission: Promise<void> | undefined;
|
|
5667
|
+
try {
|
|
5668
|
+
const real = await createTerminalizationSession(cwd, (model, context, options) =>
|
|
5669
|
+
createMockModel({
|
|
5670
|
+
responses: [
|
|
5671
|
+
async () => {
|
|
5672
|
+
entered.resolve();
|
|
5673
|
+
await release.promise;
|
|
5674
|
+
return { content: ["completed"] };
|
|
5675
|
+
},
|
|
5676
|
+
],
|
|
5677
|
+
}).stream(model, context, options),
|
|
5678
|
+
);
|
|
5679
|
+
session = real.session;
|
|
5680
|
+
authStorage = real.authStorage;
|
|
5681
|
+
const send = realSendUserMessage(session);
|
|
5682
|
+
harness = await invocationHarness("dispose-publication", cwd, {
|
|
5683
|
+
isIdle: () => !session?.isStreaming,
|
|
5684
|
+
sendUserMessage: (content, options) => {
|
|
5685
|
+
submission = send(content, options);
|
|
5686
|
+
void submission.catch(() => undefined);
|
|
5687
|
+
return submission;
|
|
5688
|
+
},
|
|
5689
|
+
});
|
|
5690
|
+
session.subscribe(event => harness?.emit(event.type, event));
|
|
5691
|
+
expect((await harness.control("turn.prompt", { text: "dispose this accepted prompt" })).ok).toBe(true);
|
|
5692
|
+
await entered.promise;
|
|
5693
|
+
const disposal = session.dispose();
|
|
5694
|
+
release.resolve();
|
|
5695
|
+
if (!submission) throw new Error("Expected the actual accepted submission promise.");
|
|
5696
|
+
await expect(submission).rejects.toMatchObject({ code: "cancelled" });
|
|
5697
|
+
await disposal;
|
|
5698
|
+
} finally {
|
|
5699
|
+
release.resolve();
|
|
5700
|
+
await session?.dispose();
|
|
5701
|
+
authStorage?.close();
|
|
5702
|
+
await harness?.stop();
|
|
5703
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5704
|
+
}
|
|
5705
|
+
});
|
|
5706
|
+
|
|
5707
|
+
test.each([
|
|
5708
|
+
false,
|
|
5709
|
+
true,
|
|
5710
|
+
])("SDK terminal publication rejection is observed independently of its cohort waiter (queued=%s)", async queued => {
|
|
5711
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-publication-rejection-"));
|
|
5712
|
+
const entered = Promise.withResolvers<void>();
|
|
5713
|
+
const release = Promise.withResolvers<void>();
|
|
5714
|
+
const rejected = Promise.withResolvers<void>();
|
|
5715
|
+
const failure = new Error("Injected terminal extension delivery failure.");
|
|
5716
|
+
const unhandled: unknown[] = [];
|
|
5717
|
+
const onUnhandled = (reason: unknown) => {
|
|
5718
|
+
unhandled.push(reason);
|
|
5719
|
+
};
|
|
5720
|
+
let session: AgentSession | undefined;
|
|
5721
|
+
let authStorage: AuthStorage | undefined;
|
|
5722
|
+
let harness: InvocationHarness | undefined;
|
|
5723
|
+
let rootSubmission: Promise<void> | undefined;
|
|
5724
|
+
let providerCalls = 0;
|
|
5725
|
+
let targetToken: string | undefined;
|
|
5726
|
+
let injected = false;
|
|
5727
|
+
const projectedTerminals: Array<{ calls: number; token: string | undefined }> = [];
|
|
5728
|
+
let restoreFault: (() => void) | undefined;
|
|
5729
|
+
process.on("unhandledRejection", onUnhandled);
|
|
5730
|
+
try {
|
|
5731
|
+
const real = await createTerminalizationSession(
|
|
5732
|
+
cwd,
|
|
5733
|
+
(model, context, options) => {
|
|
5734
|
+
providerCalls++;
|
|
5735
|
+
return createMockModel({
|
|
5736
|
+
responses: [
|
|
5737
|
+
async () => {
|
|
5738
|
+
if (providerCalls === 1) {
|
|
5739
|
+
entered.resolve();
|
|
5740
|
+
await release.promise;
|
|
5741
|
+
}
|
|
5742
|
+
return { content: ["completed"] };
|
|
5743
|
+
},
|
|
5744
|
+
],
|
|
5745
|
+
}).stream(model, context, options);
|
|
5746
|
+
},
|
|
5747
|
+
{},
|
|
5748
|
+
true,
|
|
5749
|
+
);
|
|
5750
|
+
session = real.session;
|
|
5751
|
+
authStorage = real.authStorage;
|
|
5752
|
+
const runner = session.extensionRunner;
|
|
5753
|
+
if (!runner) throw new Error("Expected the actual extension runner.");
|
|
5754
|
+
const emit = runner.emit.bind(runner);
|
|
5755
|
+
const fault = spyOn(runner, "emit").mockImplementation(async (event, ...args) => {
|
|
5756
|
+
if (event.type === "agent_end") projectedTerminals.push({ calls: providerCalls, token: event.sdkRunToken });
|
|
5757
|
+
if (!injected && event.type === "agent_end" && event.sdkRunToken === targetToken) {
|
|
5758
|
+
injected = true;
|
|
5759
|
+
rejected.resolve();
|
|
5760
|
+
throw failure;
|
|
5761
|
+
}
|
|
5762
|
+
return emit(event, ...args);
|
|
5763
|
+
});
|
|
5764
|
+
restoreFault = () => fault.mockRestore();
|
|
5765
|
+
const send = realSendUserMessage(session);
|
|
5766
|
+
harness = await invocationHarness("publication-rejection", cwd, {
|
|
5767
|
+
isIdle: () => !session?.isStreaming,
|
|
5768
|
+
sendUserMessage: (content, options) => {
|
|
5769
|
+
const submission = send(content, options);
|
|
5770
|
+
if (!rootSubmission) rootSubmission = submission;
|
|
5771
|
+
void submission.catch(() => undefined);
|
|
5772
|
+
return submission;
|
|
5773
|
+
},
|
|
5774
|
+
});
|
|
5775
|
+
session.subscribe(event => harness?.emit(event.type, event));
|
|
5776
|
+
const active = await harness.control("turn.prompt", { text: "active prompt" });
|
|
5777
|
+
expect(active.ok).toBe(true);
|
|
5778
|
+
await entered.promise;
|
|
5779
|
+
const target = queued ? await harness.control("turn.follow_up", { text: "queued prompt" }) : active;
|
|
5780
|
+
expect(target.ok).toBe(true);
|
|
5781
|
+
targetToken = `${target.result?.commandId}:${target.result?.turnId}`;
|
|
5782
|
+
release.resolve();
|
|
5783
|
+
await rejected.promise;
|
|
5784
|
+
if (!rootSubmission) throw new Error("Expected the actual root submission promise.");
|
|
5785
|
+
if (queued) await rootSubmission;
|
|
5786
|
+
else await expect(rootSubmission).rejects.toBe(failure);
|
|
5787
|
+
await Bun.sleep(0);
|
|
5788
|
+
await Bun.sleep(0);
|
|
5789
|
+
expect(injected).toBe(true);
|
|
5790
|
+
expect(providerCalls).toBe(queued ? 2 : 1);
|
|
5791
|
+
expect(projectedTerminals).toHaveLength(queued ? 2 : 1);
|
|
5792
|
+
expect(unhandled).toEqual([]);
|
|
5793
|
+
} finally {
|
|
5794
|
+
process.off("unhandledRejection", onUnhandled);
|
|
5795
|
+
restoreFault?.();
|
|
5796
|
+
release.resolve();
|
|
5797
|
+
await session?.dispose().catch(error => {
|
|
5798
|
+
expect(error).toBeInstanceOf(AggregateError);
|
|
5799
|
+
expect((error as AggregateError).errors).toEqual([failure]);
|
|
5800
|
+
});
|
|
5801
|
+
authStorage?.close();
|
|
5802
|
+
await harness?.stop();
|
|
5803
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5804
|
+
}
|
|
5805
|
+
});
|
|
5806
|
+
|
|
5807
|
+
test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
|
|
5808
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
|
|
5809
|
+
const staleToken = "ended-command:ended-turn";
|
|
5810
|
+
const clientRef = "stale-steer-ref";
|
|
5811
|
+
const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
|
|
5812
|
+
let harness: InvocationHarness | undefined;
|
|
5813
|
+
try {
|
|
5814
|
+
harness = await invocationHarness("steer-run-token", cwd, {
|
|
4535
5815
|
sendUserMessage: async (content, options) => {
|
|
4536
5816
|
calls.push({
|
|
4537
5817
|
content,
|
|
@@ -4562,6 +5842,533 @@ test("SDK turn.steer preserves its expected run token and propagates a stale-run
|
|
|
4562
5842
|
});
|
|
4563
5843
|
|
|
4564
5844
|
describe("post-acceptance invocation terminalization", () => {
|
|
5845
|
+
test.each([
|
|
5846
|
+
false,
|
|
5847
|
+
true,
|
|
5848
|
+
])("a returning SDK bridge waits for its real overflow continuation terminal (managed=%s)", async managed => {
|
|
5849
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-returning-bridge-"));
|
|
5850
|
+
let harness: InvocationHarness | undefined;
|
|
5851
|
+
let session: AgentSession | undefined;
|
|
5852
|
+
let authStorage: AuthStorage | undefined;
|
|
5853
|
+
let submission: Promise<void> | undefined;
|
|
5854
|
+
let submissionSettled = false;
|
|
5855
|
+
let providerCalls = 0;
|
|
5856
|
+
const recoveryEntered = Promise.withResolvers<void>();
|
|
5857
|
+
const releaseRecovery = Promise.withResolvers<void>();
|
|
5858
|
+
try {
|
|
5859
|
+
const real = await createTerminalizationSession(cwd, (model, context, options) => {
|
|
5860
|
+
providerCalls++;
|
|
5861
|
+
if (providerCalls === 1) return typedContextOverflowStream(model);
|
|
5862
|
+
return createMockModel({
|
|
5863
|
+
responses: [
|
|
5864
|
+
async () => {
|
|
5865
|
+
recoveryEntered.resolve();
|
|
5866
|
+
await releaseRecovery.promise;
|
|
5867
|
+
return { content: ["FINAL_OVERFLOW_RECOVERY"] };
|
|
5868
|
+
},
|
|
5869
|
+
],
|
|
5870
|
+
}).stream(model, context, options);
|
|
5871
|
+
});
|
|
5872
|
+
session = real.session;
|
|
5873
|
+
authStorage = real.authStorage;
|
|
5874
|
+
if (!managed) session.setConfiguredModelChain("default", [selector(real.model)], "returning-overflow-test");
|
|
5875
|
+
const send = realSendUserMessage(session);
|
|
5876
|
+
harness = await invocationHarness("overflow-returning-bridge", cwd, {
|
|
5877
|
+
isIdle: () => !session?.isStreaming,
|
|
5878
|
+
sendUserMessage: (content, options) => {
|
|
5879
|
+
submission = send(content, options);
|
|
5880
|
+
void submission.then(
|
|
5881
|
+
() => {
|
|
5882
|
+
submissionSettled = true;
|
|
5883
|
+
},
|
|
5884
|
+
() => {
|
|
5885
|
+
submissionSettled = true;
|
|
5886
|
+
},
|
|
5887
|
+
);
|
|
5888
|
+
return submission;
|
|
5889
|
+
},
|
|
5890
|
+
});
|
|
5891
|
+
session.subscribe(event => harness?.emit(event.type, event));
|
|
5892
|
+
const accepted = await harness.control("turn.prompt", { text: "finish after the context overflow" });
|
|
5893
|
+
expect(accepted.ok).toBe(true);
|
|
5894
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
5895
|
+
expect(correlation.commandId).toBeDefined();
|
|
5896
|
+
expect(correlation.turnId).toBeDefined();
|
|
5897
|
+
await recoveryEntered.promise;
|
|
5898
|
+
expect(submissionSettled).toBe(false);
|
|
5899
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
|
|
5900
|
+
expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
|
|
5901
|
+
status: expect.stringMatching(/^(accepted|in_flight)$/),
|
|
5902
|
+
});
|
|
5903
|
+
releaseRecovery.resolve();
|
|
5904
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
5905
|
+
expect(terminal).toMatchObject({
|
|
5906
|
+
...correlation,
|
|
5907
|
+
status: "terminal_ok",
|
|
5908
|
+
content: { text: "FINAL_OVERFLOW_RECOVERY" },
|
|
5909
|
+
outcome: { kind: "stopped", reason: "end_turn" },
|
|
5910
|
+
});
|
|
5911
|
+
expect(terminal.error).toBeUndefined();
|
|
5912
|
+
if (!submission) throw new Error("Expected the actual accepted overflow submission promise.");
|
|
5913
|
+
await submission;
|
|
5914
|
+
expect(submissionSettled).toBe(true);
|
|
5915
|
+
await session.waitForIdle();
|
|
5916
|
+
const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
|
|
5917
|
+
expect(ends).toHaveLength(1);
|
|
5918
|
+
expect(ends[0]).toMatchObject({
|
|
5919
|
+
payload: { ...correlation, outcome: { kind: "stopped", reason: "end_turn" } },
|
|
5920
|
+
});
|
|
5921
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(managed ? 1 : 2);
|
|
5922
|
+
expect(providerCalls).toBe(2);
|
|
5923
|
+
} finally {
|
|
5924
|
+
releaseRecovery.resolve();
|
|
5925
|
+
await session?.dispose();
|
|
5926
|
+
authStorage?.close();
|
|
5927
|
+
await harness?.stop();
|
|
5928
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5929
|
+
}
|
|
5930
|
+
});
|
|
5931
|
+
|
|
5932
|
+
test("preserves prompt correlation across a real overflow retry", async () => {
|
|
5933
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-correlation-"));
|
|
5934
|
+
let harness: InvocationHarness | undefined;
|
|
5935
|
+
let session: AgentSession | undefined;
|
|
5936
|
+
let authStorage: AuthStorage | undefined;
|
|
5937
|
+
let providerCalls = 0;
|
|
5938
|
+
try {
|
|
5939
|
+
const real = await createTerminalizationSession(cwd, async (model, context, options) => {
|
|
5940
|
+
providerCalls++;
|
|
5941
|
+
if (providerCalls === 1) return typedContextOverflowStream(model);
|
|
5942
|
+
return createMockModel({ responses: [{ content: ["recovered"] }] }).stream(model, context, options);
|
|
5943
|
+
});
|
|
5944
|
+
session = real.session;
|
|
5945
|
+
authStorage = real.authStorage;
|
|
5946
|
+
|
|
5947
|
+
harness = await invocationHarness("overflow-retry-correlation", cwd, {
|
|
5948
|
+
isIdle: () => !session?.isStreaming,
|
|
5949
|
+
sendUserMessage: deferredRealSendUserMessage(session),
|
|
5950
|
+
});
|
|
5951
|
+
session.subscribe(async event => {
|
|
5952
|
+
await harness?.emit(event.type, event);
|
|
5953
|
+
});
|
|
5954
|
+
const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
|
|
5955
|
+
expect(accepted.ok).toBe(true);
|
|
5956
|
+
const correlation = {
|
|
5957
|
+
commandId: accepted.result?.commandId,
|
|
5958
|
+
turnId: accepted.result?.turnId,
|
|
5959
|
+
};
|
|
5960
|
+
|
|
5961
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
5962
|
+
expect(terminal).toMatchObject({
|
|
5963
|
+
status: "terminal_ok",
|
|
5964
|
+
commandId: correlation.commandId,
|
|
5965
|
+
turnId: correlation.turnId,
|
|
5966
|
+
});
|
|
5967
|
+
const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
|
|
5968
|
+
expect(ends).toHaveLength(1);
|
|
5969
|
+
expect(ends[0]).toMatchObject({
|
|
5970
|
+
payload: {
|
|
5971
|
+
commandId: correlation.commandId,
|
|
5972
|
+
turnId: correlation.turnId,
|
|
5973
|
+
},
|
|
5974
|
+
});
|
|
5975
|
+
expect(providerCalls).toBe(2);
|
|
5976
|
+
} finally {
|
|
5977
|
+
await session?.dispose();
|
|
5978
|
+
authStorage?.close();
|
|
5979
|
+
await harness?.stop();
|
|
5980
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5981
|
+
}
|
|
5982
|
+
});
|
|
5983
|
+
|
|
5984
|
+
test("preserves prompt correlation across a todo-reminder continuation", async () => {
|
|
5985
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-continuation-"));
|
|
5986
|
+
let harness: InvocationHarness | undefined;
|
|
5987
|
+
let session: AgentSession | undefined;
|
|
5988
|
+
let authStorage: AuthStorage | undefined;
|
|
5989
|
+
let providerCalls = 0;
|
|
5990
|
+
const secondEntered = Promise.withResolvers<void>();
|
|
5991
|
+
const releaseSecond = Promise.withResolvers<void>();
|
|
5992
|
+
try {
|
|
5993
|
+
const real = await createTerminalizationSession(
|
|
5994
|
+
cwd,
|
|
5995
|
+
async (model, context, options) => {
|
|
5996
|
+
providerCalls++;
|
|
5997
|
+
return createMockModel({
|
|
5998
|
+
responses: [
|
|
5999
|
+
providerCalls === 2
|
|
6000
|
+
? async () => {
|
|
6001
|
+
secondEntered.resolve();
|
|
6002
|
+
await releaseSecond.promise;
|
|
6003
|
+
return { content: ["FINAL"] };
|
|
6004
|
+
}
|
|
6005
|
+
: { content: ["FIRST"] },
|
|
6006
|
+
],
|
|
6007
|
+
}).stream(model, context, options);
|
|
6008
|
+
},
|
|
6009
|
+
{ "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 },
|
|
6010
|
+
);
|
|
6011
|
+
session = real.session;
|
|
6012
|
+
authStorage = real.authStorage;
|
|
6013
|
+
|
|
6014
|
+
session.setTodoPhases([
|
|
6015
|
+
{ name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
|
|
6016
|
+
]);
|
|
6017
|
+
harness = await invocationHarness("todo-reminder-continuation", cwd, {
|
|
6018
|
+
isIdle: () => !session?.isStreaming,
|
|
6019
|
+
sendUserMessage: realSendUserMessage(session),
|
|
6020
|
+
});
|
|
6021
|
+
session.subscribe(async event => {
|
|
6022
|
+
await harness?.emit(event.type, event);
|
|
6023
|
+
});
|
|
6024
|
+
const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
|
|
6025
|
+
expect(accepted.ok).toBe(true);
|
|
6026
|
+
const correlation = {
|
|
6027
|
+
commandId: accepted.result?.commandId,
|
|
6028
|
+
turnId: accepted.result?.turnId,
|
|
6029
|
+
};
|
|
6030
|
+
expect(correlation.commandId).toBeDefined();
|
|
6031
|
+
expect(correlation.turnId).toBeDefined();
|
|
6032
|
+
await secondEntered.promise;
|
|
6033
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
|
|
6034
|
+
expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
|
|
6035
|
+
status: expect.stringMatching(/^(accepted|in_flight)$/),
|
|
6036
|
+
});
|
|
6037
|
+
releaseSecond.resolve();
|
|
6038
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6039
|
+
expect(terminal).toMatchObject({
|
|
6040
|
+
status: "terminal_ok",
|
|
6041
|
+
commandId: correlation.commandId,
|
|
6042
|
+
turnId: correlation.turnId,
|
|
6043
|
+
content: { text: "FINAL" },
|
|
6044
|
+
});
|
|
6045
|
+
expect(terminal.error).toBeUndefined();
|
|
6046
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2);
|
|
6047
|
+
const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
|
|
6048
|
+
expect(ends).toHaveLength(1);
|
|
6049
|
+
expect(ends[0]).toMatchObject({
|
|
6050
|
+
payload: {
|
|
6051
|
+
commandId: correlation.commandId,
|
|
6052
|
+
turnId: correlation.turnId,
|
|
6053
|
+
},
|
|
6054
|
+
});
|
|
6055
|
+
expect(providerCalls).toBe(2);
|
|
6056
|
+
} finally {
|
|
6057
|
+
releaseSecond.resolve();
|
|
6058
|
+
await session?.dispose();
|
|
6059
|
+
authStorage?.close();
|
|
6060
|
+
await harness?.stop();
|
|
6061
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6062
|
+
}
|
|
6063
|
+
});
|
|
6064
|
+
|
|
6065
|
+
test("terminalizes cancellation during a todo-reminder continuation", async () => {
|
|
6066
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-cancel-"));
|
|
6067
|
+
let harness: InvocationHarness | undefined;
|
|
6068
|
+
let session: AgentSession | undefined;
|
|
6069
|
+
let authStorage: AuthStorage | undefined;
|
|
6070
|
+
let providerCalls = 0;
|
|
6071
|
+
let secondSignal: AbortSignal | undefined;
|
|
6072
|
+
const secondEntered = Promise.withResolvers<void>();
|
|
6073
|
+
const releaseSecond = Promise.withResolvers<void>();
|
|
6074
|
+
try {
|
|
6075
|
+
const real = await createTerminalizationSession(
|
|
6076
|
+
cwd,
|
|
6077
|
+
async (model, context, options) => {
|
|
6078
|
+
providerCalls++;
|
|
6079
|
+
if (providerCalls === 2) {
|
|
6080
|
+
secondSignal = options?.signal;
|
|
6081
|
+
secondEntered.resolve();
|
|
6082
|
+
}
|
|
6083
|
+
const response =
|
|
6084
|
+
providerCalls === 2
|
|
6085
|
+
? async () => {
|
|
6086
|
+
await releaseSecond.promise;
|
|
6087
|
+
return { content: ["completed"] };
|
|
6088
|
+
}
|
|
6089
|
+
: { content: ["started"] };
|
|
6090
|
+
return createMockModel({ responses: [response] }).stream(model, context, options);
|
|
6091
|
+
},
|
|
6092
|
+
{ "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 },
|
|
6093
|
+
);
|
|
6094
|
+
session = real.session;
|
|
6095
|
+
authStorage = real.authStorage;
|
|
6096
|
+
session.setTodoPhases([
|
|
6097
|
+
{ name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
|
|
6098
|
+
]);
|
|
6099
|
+
harness = await invocationHarness("todo-reminder-cancel", cwd, {
|
|
6100
|
+
isIdle: () => !session?.isStreaming,
|
|
6101
|
+
sendUserMessage: realSendUserMessage(session),
|
|
6102
|
+
});
|
|
6103
|
+
session.subscribe(async event => {
|
|
6104
|
+
await harness?.emit(event.type, event);
|
|
6105
|
+
});
|
|
6106
|
+
const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
|
|
6107
|
+
expect(accepted.ok).toBe(true);
|
|
6108
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
6109
|
+
expect(correlation.commandId).toBeDefined();
|
|
6110
|
+
expect(correlation.turnId).toBeDefined();
|
|
6111
|
+
await secondEntered.promise;
|
|
6112
|
+
expect(secondSignal?.aborted).toBe(false);
|
|
6113
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
|
|
6114
|
+
await session.abort();
|
|
6115
|
+
expect(secondSignal?.aborted).toBe(true);
|
|
6116
|
+
releaseSecond.resolve();
|
|
6117
|
+
await session.waitForIdle();
|
|
6118
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6119
|
+
expect(terminal).toMatchObject(correlation);
|
|
6120
|
+
const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
|
|
6121
|
+
expect(ends).toHaveLength(1);
|
|
6122
|
+
expect(ends[0]).toMatchObject({ payload: correlation });
|
|
6123
|
+
expect(providerCalls).toBe(2);
|
|
6124
|
+
} finally {
|
|
6125
|
+
releaseSecond.resolve();
|
|
6126
|
+
await session?.dispose();
|
|
6127
|
+
authStorage?.close();
|
|
6128
|
+
await harness?.stop();
|
|
6129
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6130
|
+
}
|
|
6131
|
+
});
|
|
6132
|
+
|
|
6133
|
+
test("terminalizes a synchronous throw during a todo-reminder continuation", async () => {
|
|
6134
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-throw-"));
|
|
6135
|
+
let harness: InvocationHarness | undefined;
|
|
6136
|
+
let session: AgentSession | undefined;
|
|
6137
|
+
let authStorage: AuthStorage | undefined;
|
|
6138
|
+
let providerCalls = 0;
|
|
6139
|
+
try {
|
|
6140
|
+
const real = await createTerminalizationSession(
|
|
6141
|
+
cwd,
|
|
6142
|
+
(model, context, options) => {
|
|
6143
|
+
providerCalls++;
|
|
6144
|
+
if (providerCalls === 2) throw new Error("todo continuation stream failed synchronously");
|
|
6145
|
+
return createMockModel({ responses: [{ content: ["started"] }] }).stream(model, context, options);
|
|
6146
|
+
},
|
|
6147
|
+
{ "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1, "retry.enabled": false },
|
|
6148
|
+
);
|
|
6149
|
+
session = real.session;
|
|
6150
|
+
session.setConfiguredModelChain("default", [selector(real.model)], "terminal-throw-test");
|
|
6151
|
+
authStorage = real.authStorage;
|
|
6152
|
+
session.setTodoPhases([
|
|
6153
|
+
{ name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
|
|
6154
|
+
]);
|
|
6155
|
+
harness = await invocationHarness("todo-reminder-throw", cwd, {
|
|
6156
|
+
isIdle: () => !session?.isStreaming,
|
|
6157
|
+
sendUserMessage: realSendUserMessage(session),
|
|
6158
|
+
});
|
|
6159
|
+
session.subscribe(async event => {
|
|
6160
|
+
await harness?.emit(event.type, event);
|
|
6161
|
+
});
|
|
6162
|
+
const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
|
|
6163
|
+
expect(accepted.ok).toBe(true);
|
|
6164
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
6165
|
+
expect(correlation.commandId).toBeDefined();
|
|
6166
|
+
expect(correlation.turnId).toBeDefined();
|
|
6167
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6168
|
+
expect(terminal).toMatchObject({
|
|
6169
|
+
...correlation,
|
|
6170
|
+
status: "failed",
|
|
6171
|
+
outcome: { kind: "failed" },
|
|
6172
|
+
error: { code: "provider_rejected" },
|
|
6173
|
+
});
|
|
6174
|
+
// Wait for session to settle and ensure no further continuations are queued
|
|
6175
|
+
await session?.waitForIdle();
|
|
6176
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2);
|
|
6177
|
+
const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
|
|
6178
|
+
expect(ends).toHaveLength(1);
|
|
6179
|
+
expect(ends[0]).toMatchObject({ payload: { ...correlation, outcome: { kind: "failed" } } });
|
|
6180
|
+
expect(providerCalls).toBe(2);
|
|
6181
|
+
await session.waitForIdle();
|
|
6182
|
+
expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
|
|
6183
|
+
...correlation,
|
|
6184
|
+
status: "failed",
|
|
6185
|
+
outcome: { kind: "failed" },
|
|
6186
|
+
});
|
|
6187
|
+
} finally {
|
|
6188
|
+
await session?.dispose();
|
|
6189
|
+
authStorage?.close();
|
|
6190
|
+
await harness?.stop();
|
|
6191
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6192
|
+
}
|
|
6193
|
+
});
|
|
6194
|
+
|
|
6195
|
+
test("terminalizes a second overflow during overflow maintenance", async () => {
|
|
6196
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-maintenance-failure-"));
|
|
6197
|
+
let harness: InvocationHarness | undefined;
|
|
6198
|
+
let session: AgentSession | undefined;
|
|
6199
|
+
let authStorage: AuthStorage | undefined;
|
|
6200
|
+
try {
|
|
6201
|
+
const real = await createTerminalizationSession(cwd, model => {
|
|
6202
|
+
return typedContextOverflowStream(model);
|
|
6203
|
+
});
|
|
6204
|
+
session = real.session;
|
|
6205
|
+
authStorage = real.authStorage;
|
|
6206
|
+
harness = await invocationHarness("overflow-maintenance-failure", cwd, {
|
|
6207
|
+
isIdle: () => !session?.isStreaming,
|
|
6208
|
+
sendUserMessage: deferredRealSendUserMessage(session),
|
|
6209
|
+
});
|
|
6210
|
+
session.subscribe(async event => {
|
|
6211
|
+
await harness?.emit(event.type, event);
|
|
6212
|
+
});
|
|
6213
|
+
const accepted = await harness.control("turn.prompt", { text: "retry the outstanding work" });
|
|
6214
|
+
expect(accepted.ok).toBe(true);
|
|
6215
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
6216
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6217
|
+
expect(terminal).toMatchObject({
|
|
6218
|
+
status: "failed",
|
|
6219
|
+
commandId: correlation.commandId,
|
|
6220
|
+
turnId: correlation.turnId,
|
|
6221
|
+
});
|
|
6222
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
|
|
6223
|
+
} finally {
|
|
6224
|
+
await session?.dispose();
|
|
6225
|
+
authStorage?.close();
|
|
6226
|
+
await harness?.stop();
|
|
6227
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6228
|
+
}
|
|
6229
|
+
});
|
|
6230
|
+
|
|
6231
|
+
test("terminalizes cancellation during a scheduled overflow retry", async () => {
|
|
6232
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-cancel-"));
|
|
6233
|
+
let harness: InvocationHarness | undefined;
|
|
6234
|
+
let session: AgentSession | undefined;
|
|
6235
|
+
let authStorage: AuthStorage | undefined;
|
|
6236
|
+
let providerCalls = 0;
|
|
6237
|
+
const overflowEmitted = Promise.withResolvers<void>();
|
|
6238
|
+
try {
|
|
6239
|
+
const real = await createTerminalizationSession(
|
|
6240
|
+
cwd,
|
|
6241
|
+
model => {
|
|
6242
|
+
providerCalls++;
|
|
6243
|
+
return typedContextOverflowStream(
|
|
6244
|
+
model,
|
|
6245
|
+
providerCalls === 1 ? () => overflowEmitted.resolve() : undefined,
|
|
6246
|
+
);
|
|
6247
|
+
},
|
|
6248
|
+
{ "retry.baseDelayMs": 1_000 },
|
|
6249
|
+
);
|
|
6250
|
+
session = real.session;
|
|
6251
|
+
authStorage = real.authStorage;
|
|
6252
|
+
session.setClientBridge({ capabilities: {}, deferAgentInitiatedTurns: true });
|
|
6253
|
+
harness = await invocationHarness("overflow-retry-cancel", cwd, {
|
|
6254
|
+
isIdle: () => !session?.isStreaming,
|
|
6255
|
+
sendUserMessage: deferredRealSendUserMessage(session),
|
|
6256
|
+
});
|
|
6257
|
+
session.subscribe(async event => {
|
|
6258
|
+
await harness?.emit(event.type, event);
|
|
6259
|
+
});
|
|
6260
|
+
const accepted = await harness.control("turn.prompt", { text: "cancel the retry" });
|
|
6261
|
+
expect(accepted.ok).toBe(true);
|
|
6262
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
6263
|
+
await overflowEmitted.promise;
|
|
6264
|
+
await harness.control("turn.cancel", correlation);
|
|
6265
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6266
|
+
expect(terminal.status).toBe("failed");
|
|
6267
|
+
expect(terminal).toMatchObject(correlation);
|
|
6268
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
|
|
6269
|
+
await session.waitForIdle();
|
|
6270
|
+
expect(
|
|
6271
|
+
harness.broadcasts.filter(
|
|
6272
|
+
frame =>
|
|
6273
|
+
frame.kind === "agent_end" &&
|
|
6274
|
+
(frame.payload as { commandId?: string }).commandId === correlation.commandId,
|
|
6275
|
+
),
|
|
6276
|
+
).toHaveLength(1);
|
|
6277
|
+
} finally {
|
|
6278
|
+
await session?.dispose();
|
|
6279
|
+
authStorage?.close();
|
|
6280
|
+
await harness?.stop();
|
|
6281
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6282
|
+
}
|
|
6283
|
+
});
|
|
6284
|
+
|
|
6285
|
+
test("terminalizes a synchronous throw from the overflow retry stream", async () => {
|
|
6286
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-throw-"));
|
|
6287
|
+
let harness: InvocationHarness | undefined;
|
|
6288
|
+
let session: AgentSession | undefined;
|
|
6289
|
+
let authStorage: AuthStorage | undefined;
|
|
6290
|
+
let providerCalls = 0;
|
|
6291
|
+
try {
|
|
6292
|
+
const real = await createTerminalizationSession(cwd, async model => {
|
|
6293
|
+
providerCalls++;
|
|
6294
|
+
if (providerCalls === 2) throw new Error("retry stream failed synchronously");
|
|
6295
|
+
return typedContextOverflowStream(model);
|
|
6296
|
+
});
|
|
6297
|
+
session = real.session;
|
|
6298
|
+
authStorage = real.authStorage;
|
|
6299
|
+
harness = await invocationHarness("overflow-retry-throw", cwd, {
|
|
6300
|
+
isIdle: () => !session?.isStreaming,
|
|
6301
|
+
sendUserMessage: deferredRealSendUserMessage(session),
|
|
6302
|
+
});
|
|
6303
|
+
session.subscribe(async event => {
|
|
6304
|
+
await harness?.emit(event.type, event);
|
|
6305
|
+
});
|
|
6306
|
+
const accepted = await harness.control("turn.prompt", { text: "retry with a throwing provider" });
|
|
6307
|
+
expect(accepted.ok).toBe(true);
|
|
6308
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
6309
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6310
|
+
expect(terminal).toMatchObject({
|
|
6311
|
+
status: "failed",
|
|
6312
|
+
commandId: correlation.commandId,
|
|
6313
|
+
turnId: correlation.turnId,
|
|
6314
|
+
});
|
|
6315
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
|
|
6316
|
+
} finally {
|
|
6317
|
+
await session?.dispose();
|
|
6318
|
+
authStorage?.close();
|
|
6319
|
+
await harness?.stop();
|
|
6320
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6321
|
+
}
|
|
6322
|
+
});
|
|
6323
|
+
|
|
6324
|
+
test("preserves external prompt correlation across a retryable provider error", async () => {
|
|
6325
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-retryable-provider-correlation-"));
|
|
6326
|
+
let harness: InvocationHarness | undefined;
|
|
6327
|
+
try {
|
|
6328
|
+
harness = await invocationHarness("retryable-provider-correlation", cwd, {
|
|
6329
|
+
sendUserMessage: async (_content, options) => {
|
|
6330
|
+
await options?.onPreflightAcceptCommit?.();
|
|
6331
|
+
await neverSettlingPromise();
|
|
6332
|
+
},
|
|
6333
|
+
});
|
|
6334
|
+
const accepted = await harness.control("turn.prompt", {
|
|
6335
|
+
text: "recover after a provider overload",
|
|
6336
|
+
clientRef: "retryable-provider-correlation",
|
|
6337
|
+
});
|
|
6338
|
+
expect(accepted.ok).toBe(true);
|
|
6339
|
+
const correlation = {
|
|
6340
|
+
commandId: accepted.result?.commandId,
|
|
6341
|
+
turnId: accepted.result?.turnId,
|
|
6342
|
+
};
|
|
6343
|
+
const retryScope = {};
|
|
6344
|
+
|
|
6345
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
6346
|
+
await harness.emit("agent_start", { lifecycleScope: retryScope });
|
|
6347
|
+
}
|
|
6348
|
+
await harness.emit("agent_end", {
|
|
6349
|
+
messages: [{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "recovered" }] }],
|
|
6350
|
+
});
|
|
6351
|
+
|
|
6352
|
+
const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
|
|
6353
|
+
expect(terminal).toMatchObject({
|
|
6354
|
+
status: "terminal_ok",
|
|
6355
|
+
commandId: correlation.commandId,
|
|
6356
|
+
turnId: correlation.turnId,
|
|
6357
|
+
outcome: { kind: "stopped", reason: "end_turn" },
|
|
6358
|
+
});
|
|
6359
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").at(-1)).toMatchObject({
|
|
6360
|
+
payload: {
|
|
6361
|
+
commandId: correlation.commandId,
|
|
6362
|
+
turnId: correlation.turnId,
|
|
6363
|
+
outcome: { kind: "stopped", reason: "end_turn" },
|
|
6364
|
+
},
|
|
6365
|
+
});
|
|
6366
|
+
} finally {
|
|
6367
|
+
await harness?.stop();
|
|
6368
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6369
|
+
}
|
|
6370
|
+
});
|
|
6371
|
+
|
|
4565
6372
|
test.each([
|
|
4566
6373
|
{ status: 402, code: "provider_http_402" },
|
|
4567
6374
|
{ status: 429, code: "provider_http_429" },
|
|
@@ -5209,6 +7016,85 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5209
7016
|
}
|
|
5210
7017
|
});
|
|
5211
7018
|
|
|
7019
|
+
test("tokenless retry boundaries release replacement terminal publication and replay", async () => {
|
|
7020
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-tokenless-retry-reopen-"));
|
|
7021
|
+
const sessionId = "tokenless-retry-reopen";
|
|
7022
|
+
const harness = await invocationHarness(sessionId, cwd, {});
|
|
7023
|
+
const scopes = createAttemptMinter();
|
|
7024
|
+
const activity = spyOn(SessionSdkSessionRuntime.prototype, "reportActivity");
|
|
7025
|
+
try {
|
|
7026
|
+
// Accepted ordinary retries suppress predecessor ends. Three attempts
|
|
7027
|
+
// therefore share one public lifecycle boundary, not three unmatched ends.
|
|
7028
|
+
const lifecycleScope = scopes.mint("main");
|
|
7029
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
7030
|
+
await harness.emit("agent_start", { lifecycleScope });
|
|
7031
|
+
}
|
|
7032
|
+
await harness.emit("agent_end");
|
|
7033
|
+
for (let turn = 0; turn < 2; turn++) {
|
|
7034
|
+
await harness.emit("agent_start", { lifecycleScope: scopes.mint("main") });
|
|
7035
|
+
await harness.emit("agent_end");
|
|
7036
|
+
}
|
|
7037
|
+
await harness.switchSession("different-session");
|
|
7038
|
+
await harness.switchSession(sessionId);
|
|
7039
|
+
const priorEnds = harness.broadcasts.filter(frame => frame.kind === "agent_end").length;
|
|
7040
|
+
await harness.emit("agent_start", { lifecycleScope: scopes.mint("main") });
|
|
7041
|
+
await harness.emit("agent_end");
|
|
7042
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(priorEnds + 1);
|
|
7043
|
+
expect(activity.mock.calls.at(-1)?.[0]).toBe("idle");
|
|
7044
|
+
expect(await harness.requestOnSession(sessionId, { type: "event_replay", sinceSeq: 0 })).toMatchObject({
|
|
7045
|
+
type: "event_replay_result",
|
|
7046
|
+
events: expect.arrayContaining([expect.objectContaining({ kind: "agent_end" })]),
|
|
7047
|
+
});
|
|
7048
|
+
} finally {
|
|
7049
|
+
activity.mockRestore();
|
|
7050
|
+
await harness.stop();
|
|
7051
|
+
await rm(cwd, { recursive: true, force: true });
|
|
7052
|
+
}
|
|
7053
|
+
});
|
|
7054
|
+
|
|
7055
|
+
test("tokenless retry preserves attached owners alongside newly promoted SDK work", async () => {
|
|
7056
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-tokenless-retry-owners-"));
|
|
7057
|
+
const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
|
|
7058
|
+
const harness = await invocationHarness("tokenless-retry-owners", cwd, {
|
|
7059
|
+
sendUserMessage: async (_content, options) => {
|
|
7060
|
+
await options?.onPreflightAcceptCommit?.();
|
|
7061
|
+
if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
|
|
7062
|
+
options?.onPreflightAccepted?.();
|
|
7063
|
+
},
|
|
7064
|
+
});
|
|
7065
|
+
try {
|
|
7066
|
+
const lifecycleScope = createAttemptMinter().mint("main");
|
|
7067
|
+
await harness.emit("agent_start", { lifecycleScope });
|
|
7068
|
+
const attached = await harness.control("turn.follow_up", { text: "attached work" });
|
|
7069
|
+
const queued = await harness.control("turn.follow_up", { text: "promoted work" });
|
|
7070
|
+
expect(attached.ok).toBe(true);
|
|
7071
|
+
expect(queued.ok).toBe(true);
|
|
7072
|
+
expect(promotions).toHaveLength(2);
|
|
7073
|
+
promotions[0]!({ startsOwnRun: false });
|
|
7074
|
+
promotions[1]!({ startsOwnRun: true });
|
|
7075
|
+
await harness.emit("agent_start", { lifecycleScope });
|
|
7076
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "shared retry final" }] });
|
|
7077
|
+
for (const accepted of [attached, queued]) {
|
|
7078
|
+
expect(
|
|
7079
|
+
await harness.query("turn.result", {
|
|
7080
|
+
kind: "prompt",
|
|
7081
|
+
commandId: accepted.result?.commandId,
|
|
7082
|
+
turnId: accepted.result?.turnId,
|
|
7083
|
+
}),
|
|
7084
|
+
).toMatchObject({
|
|
7085
|
+
result: {
|
|
7086
|
+
status: "terminal_ok",
|
|
7087
|
+
receiptState: "present",
|
|
7088
|
+
content: { text: "shared retry final" },
|
|
7089
|
+
},
|
|
7090
|
+
});
|
|
7091
|
+
}
|
|
7092
|
+
} finally {
|
|
7093
|
+
await harness.stop();
|
|
7094
|
+
await rm(cwd, { recursive: true, force: true });
|
|
7095
|
+
}
|
|
7096
|
+
});
|
|
7097
|
+
|
|
5212
7098
|
test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
|
|
5213
7099
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
|
|
5214
7100
|
try {
|
|
@@ -5274,6 +7160,129 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5274
7160
|
await rm(cwd, { recursive: true, force: true });
|
|
5275
7161
|
}
|
|
5276
7162
|
});
|
|
7163
|
+
test("unowned predecessor end cannot publish a queued successor receipt", async () => {
|
|
7164
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-predecessor-"));
|
|
7165
|
+
const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
|
|
7166
|
+
const harness = await invocationHarness("unowned-predecessor", cwd, {
|
|
7167
|
+
sendUserMessage: async (_content, options) => {
|
|
7168
|
+
await options?.onPreflightAcceptCommit?.();
|
|
7169
|
+
if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
|
|
7170
|
+
options?.onPreflightAccepted?.();
|
|
7171
|
+
},
|
|
7172
|
+
});
|
|
7173
|
+
try {
|
|
7174
|
+
await harness.emit("agent_start");
|
|
7175
|
+
const accepted = await harness.control("turn.follow_up", { text: "queued verification" });
|
|
7176
|
+
expect(accepted.ok).toBe(true);
|
|
7177
|
+
expect(promotions).toHaveLength(1);
|
|
7178
|
+
promotions[0]!({ startsOwnRun: true });
|
|
7179
|
+
await harness.emit("agent_start");
|
|
7180
|
+
const selector = {
|
|
7181
|
+
kind: "prompt",
|
|
7182
|
+
commandId: accepted.result?.commandId,
|
|
7183
|
+
turnId: accepted.result?.turnId,
|
|
7184
|
+
};
|
|
7185
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "predecessor final" }] });
|
|
7186
|
+
expect(await harness.query("turn.result", selector)).toMatchObject({
|
|
7187
|
+
result: { status: "in_flight", receiptState: "absent" },
|
|
7188
|
+
});
|
|
7189
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "verification final" }] });
|
|
7190
|
+
expect(await harness.query("turn.result", selector)).toMatchObject({
|
|
7191
|
+
result: {
|
|
7192
|
+
status: "terminal_ok",
|
|
7193
|
+
turnId: accepted.result?.turnId,
|
|
7194
|
+
receiptState: "present",
|
|
7195
|
+
content: { text: "verification final" },
|
|
7196
|
+
},
|
|
7197
|
+
});
|
|
7198
|
+
} finally {
|
|
7199
|
+
await harness.stop();
|
|
7200
|
+
await rm(cwd, { recursive: true, force: true });
|
|
7201
|
+
}
|
|
7202
|
+
});
|
|
7203
|
+
|
|
7204
|
+
test("unowned run stays paired after predecessor SDK references retire", async () => {
|
|
7205
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-retirement-"));
|
|
7206
|
+
const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
|
|
7207
|
+
const harness = await invocationHarness("unowned-retirement", cwd, {
|
|
7208
|
+
sendUserMessage: async (_content, options) => {
|
|
7209
|
+
await options?.onPreflightAcceptCommit?.();
|
|
7210
|
+
if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
|
|
7211
|
+
options?.onPreflightAccepted?.();
|
|
7212
|
+
},
|
|
7213
|
+
});
|
|
7214
|
+
try {
|
|
7215
|
+
const first = await harness.control("turn.follow_up", { text: "first" });
|
|
7216
|
+
expect(promotions).toHaveLength(1);
|
|
7217
|
+
promotions[0]!({ startsOwnRun: true });
|
|
7218
|
+
await harness.emit("agent_start");
|
|
7219
|
+
await harness.emit("agent_start");
|
|
7220
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "first final" }] });
|
|
7221
|
+
expect(
|
|
7222
|
+
await harness.query("turn.result", {
|
|
7223
|
+
kind: "prompt",
|
|
7224
|
+
commandId: first.result?.commandId,
|
|
7225
|
+
turnId: first.result?.turnId,
|
|
7226
|
+
}),
|
|
7227
|
+
).toMatchObject({ result: { status: "terminal_ok", content: { text: "first final" } } });
|
|
7228
|
+
const next = await harness.control("turn.follow_up", { text: "next" });
|
|
7229
|
+
expect(promotions).toHaveLength(2);
|
|
7230
|
+
promotions[1]!({ startsOwnRun: true });
|
|
7231
|
+
await harness.emit("agent_start");
|
|
7232
|
+
const selector = { kind: "prompt", commandId: next.result?.commandId, turnId: next.result?.turnId };
|
|
7233
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "unowned final" }] });
|
|
7234
|
+
expect(await harness.query("turn.result", selector)).toMatchObject({
|
|
7235
|
+
result: { status: "in_flight", receiptState: "absent" },
|
|
7236
|
+
});
|
|
7237
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "next final" }] });
|
|
7238
|
+
expect(await harness.query("turn.result", selector)).toMatchObject({
|
|
7239
|
+
result: { status: "terminal_ok", content: { text: "next final" } },
|
|
7240
|
+
});
|
|
7241
|
+
} finally {
|
|
7242
|
+
await harness.stop();
|
|
7243
|
+
await rm(cwd, { recursive: true, force: true });
|
|
7244
|
+
}
|
|
7245
|
+
});
|
|
7246
|
+
|
|
7247
|
+
test("unowned run retains attached invocation across predecessor completion", async () => {
|
|
7248
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-attached-"));
|
|
7249
|
+
const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
|
|
7250
|
+
const harness = await invocationHarness("unowned-attached", cwd, {
|
|
7251
|
+
sendUserMessage: async (_content, options) => {
|
|
7252
|
+
await options?.onPreflightAcceptCommit?.();
|
|
7253
|
+
if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
|
|
7254
|
+
options?.onPreflightAccepted?.();
|
|
7255
|
+
},
|
|
7256
|
+
});
|
|
7257
|
+
try {
|
|
7258
|
+
await harness.control("turn.follow_up", { text: "predecessor" });
|
|
7259
|
+
expect(promotions).toHaveLength(1);
|
|
7260
|
+
promotions[0]!({ startsOwnRun: true });
|
|
7261
|
+
await harness.emit("agent_start");
|
|
7262
|
+
await harness.emit("agent_start");
|
|
7263
|
+
const attached = await harness.control("turn.follow_up", { text: "attach to current unowned run" });
|
|
7264
|
+
expect(attached.ok).toBe(true);
|
|
7265
|
+
expect(promotions).toHaveLength(2);
|
|
7266
|
+
promotions[1]!({ startsOwnRun: false });
|
|
7267
|
+
const selector = {
|
|
7268
|
+
kind: "prompt",
|
|
7269
|
+
commandId: attached.result?.commandId,
|
|
7270
|
+
turnId: attached.result?.turnId,
|
|
7271
|
+
};
|
|
7272
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "predecessor final" }] });
|
|
7273
|
+
expect(await harness.query("turn.result", selector)).toMatchObject({
|
|
7274
|
+
result: { status: expect.stringMatching(/accepted|in_flight/), receiptState: "absent" },
|
|
7275
|
+
});
|
|
7276
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "attached final" }] });
|
|
7277
|
+
expect(await harness.query("turn.result", selector)).toMatchObject({
|
|
7278
|
+
result: { status: "terminal_ok", content: { text: "attached final" } },
|
|
7279
|
+
});
|
|
7280
|
+
} finally {
|
|
7281
|
+
await harness.stop();
|
|
7282
|
+
await rm(cwd, { recursive: true, force: true });
|
|
7283
|
+
}
|
|
7284
|
+
});
|
|
7285
|
+
|
|
5277
7286
|
test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
|
|
5278
7287
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
|
|
5279
7288
|
try {
|
|
@@ -5737,104 +7746,101 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5737
7746
|
await rm(cwd, { recursive: true, force: true });
|
|
5738
7747
|
}
|
|
5739
7748
|
});
|
|
5740
|
-
test(
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
const
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
content: testCase.content,
|
|
5812
|
-
...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
|
|
5813
|
-
},
|
|
5814
|
-
],
|
|
5815
|
-
});
|
|
5816
|
-
expect(
|
|
5817
|
-
await settledStatus(harness, "turn.result", {
|
|
5818
|
-
kind: "prompt",
|
|
5819
|
-
commandId: accepted.result?.commandId,
|
|
5820
|
-
turnId: accepted.result?.turnId,
|
|
5821
|
-
}),
|
|
5822
|
-
).toMatchObject({
|
|
5823
|
-
status: "failed",
|
|
5824
|
-
error: { code: "prompt_failed", message: "Agent run failed after execution started." },
|
|
5825
|
-
outcome: {
|
|
5826
|
-
kind: "failed",
|
|
5827
|
-
code: "prompt_failed",
|
|
5828
|
-
message: "Agent run failed after execution started.",
|
|
5829
|
-
provenance: "agent_failed",
|
|
5830
|
-
phase: "post_start",
|
|
5831
|
-
category: "agent_runtime",
|
|
7749
|
+
test.each([
|
|
7750
|
+
{ name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true },
|
|
7751
|
+
{ name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null },
|
|
7752
|
+
{ name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" },
|
|
7753
|
+
{ name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] },
|
|
7754
|
+
{ name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } },
|
|
7755
|
+
{
|
|
7756
|
+
name: "undefined-total-tokens",
|
|
7757
|
+
content: [{ type: "thinking", thinking: "" }],
|
|
7758
|
+
usage: { totalTokens: undefined },
|
|
7759
|
+
},
|
|
7760
|
+
{ name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } },
|
|
7761
|
+
{
|
|
7762
|
+
name: "nan-total-tokens",
|
|
7763
|
+
content: [{ type: "thinking", thinking: "" }],
|
|
7764
|
+
usage: { totalTokens: Number.NaN },
|
|
7765
|
+
},
|
|
7766
|
+
{
|
|
7767
|
+
name: "infinite-total-tokens",
|
|
7768
|
+
content: [{ type: "thinking", thinking: "" }],
|
|
7769
|
+
usage: { totalTokens: Number.POSITIVE_INFINITY },
|
|
7770
|
+
},
|
|
7771
|
+
{
|
|
7772
|
+
name: "incomplete-tool-call",
|
|
7773
|
+
content: [
|
|
7774
|
+
{
|
|
7775
|
+
type: "toolCall",
|
|
7776
|
+
id: "call-1",
|
|
7777
|
+
name: "read",
|
|
7778
|
+
arguments: {},
|
|
7779
|
+
incompleteArguments: true,
|
|
7780
|
+
incompleteArgumentsReason: "truncated",
|
|
7781
|
+
},
|
|
7782
|
+
],
|
|
7783
|
+
usage: { totalTokens: 0 },
|
|
7784
|
+
},
|
|
7785
|
+
{
|
|
7786
|
+
name: "malformed-tool-arguments",
|
|
7787
|
+
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }],
|
|
7788
|
+
usage: { totalTokens: 0 },
|
|
7789
|
+
},
|
|
7790
|
+
{
|
|
7791
|
+
name: "orphaned-incomplete-reason",
|
|
7792
|
+
content: [
|
|
7793
|
+
{
|
|
7794
|
+
type: "toolCall",
|
|
7795
|
+
id: "call-1",
|
|
7796
|
+
name: "read",
|
|
7797
|
+
arguments: {},
|
|
7798
|
+
incompleteArgumentsReason: "malformed",
|
|
7799
|
+
},
|
|
7800
|
+
],
|
|
7801
|
+
usage: { totalTokens: 0 },
|
|
7802
|
+
},
|
|
7803
|
+
] as const)("fails closed without independent terminal evidence ($name)", async testCase => {
|
|
7804
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`));
|
|
7805
|
+
try {
|
|
7806
|
+
const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, {
|
|
7807
|
+
sendUserMessage: async (_content, options) => {
|
|
7808
|
+
await options?.onPreflightAcceptCommit?.();
|
|
7809
|
+
await Promise.withResolvers<void>().promise;
|
|
7810
|
+
},
|
|
7811
|
+
});
|
|
7812
|
+
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
7813
|
+
await harness.emit("agent_start");
|
|
7814
|
+
await harness.emit("agent_end", {
|
|
7815
|
+
messages: [
|
|
7816
|
+
{
|
|
7817
|
+
role: "assistant",
|
|
7818
|
+
content: testCase.content,
|
|
7819
|
+
...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
|
|
5832
7820
|
},
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
await
|
|
5837
|
-
|
|
7821
|
+
],
|
|
7822
|
+
});
|
|
7823
|
+
expect(
|
|
7824
|
+
await settledStatus(harness, "turn.result", {
|
|
7825
|
+
kind: "prompt",
|
|
7826
|
+
commandId: accepted.result?.commandId,
|
|
7827
|
+
turnId: accepted.result?.turnId,
|
|
7828
|
+
}),
|
|
7829
|
+
).toMatchObject({
|
|
7830
|
+
status: "failed",
|
|
7831
|
+
error: { code: "prompt_failed", message: "Agent run failed after execution started." },
|
|
7832
|
+
outcome: {
|
|
7833
|
+
kind: "failed",
|
|
7834
|
+
code: "prompt_failed",
|
|
7835
|
+
message: "Agent run failed after execution started.",
|
|
7836
|
+
provenance: "agent_failed",
|
|
7837
|
+
phase: "post_start",
|
|
7838
|
+
category: "agent_runtime",
|
|
7839
|
+
},
|
|
7840
|
+
});
|
|
7841
|
+
await harness.stop();
|
|
7842
|
+
} finally {
|
|
7843
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5838
7844
|
}
|
|
5839
7845
|
});
|
|
5840
7846
|
test("preserves explicit cancellation for an empty zero-token turn", async () => {
|
|
@@ -5898,114 +7904,111 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5898
7904
|
await rm(cwd, { recursive: true, force: true });
|
|
5899
7905
|
}
|
|
5900
7906
|
});
|
|
5901
|
-
test(
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
const
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
7907
|
+
test.each([
|
|
7908
|
+
{
|
|
7909
|
+
name: "completion-real-agent-real",
|
|
7910
|
+
order: "completion-first",
|
|
7911
|
+
completion: "completion",
|
|
7912
|
+
agent: "agent",
|
|
7913
|
+
expected: "completion",
|
|
7914
|
+
},
|
|
7915
|
+
{
|
|
7916
|
+
name: "completion-blank-agent-real",
|
|
7917
|
+
order: "completion-first",
|
|
7918
|
+
completion: " ",
|
|
7919
|
+
agent: "agent",
|
|
7920
|
+
expected: "agent",
|
|
7921
|
+
},
|
|
7922
|
+
{
|
|
7923
|
+
name: "completion-none-agent-real",
|
|
7924
|
+
order: "completion-first",
|
|
7925
|
+
completion: null,
|
|
7926
|
+
agent: "agent",
|
|
7927
|
+
expected: "agent",
|
|
7928
|
+
},
|
|
7929
|
+
{
|
|
7930
|
+
name: "agent-real-completion-real",
|
|
7931
|
+
order: "agent-first",
|
|
7932
|
+
completion: "completion",
|
|
7933
|
+
agent: "agent",
|
|
7934
|
+
expected: "agent",
|
|
7935
|
+
},
|
|
7936
|
+
{
|
|
7937
|
+
name: "agent-real-completion-blank",
|
|
7938
|
+
order: "agent-first",
|
|
7939
|
+
completion: " ",
|
|
7940
|
+
agent: "agent",
|
|
7941
|
+
expected: "agent",
|
|
7942
|
+
},
|
|
7943
|
+
{
|
|
7944
|
+
name: "agent-blank-completion-real",
|
|
7945
|
+
order: "agent-first",
|
|
7946
|
+
completion: "completion",
|
|
7947
|
+
agent: " ",
|
|
7948
|
+
expected: "completion",
|
|
7949
|
+
},
|
|
7950
|
+
{
|
|
7951
|
+
name: "agent-blank-completion-none",
|
|
7952
|
+
order: "agent-first",
|
|
7953
|
+
completion: null,
|
|
7954
|
+
agent: " ",
|
|
7955
|
+
expected: undefined,
|
|
7956
|
+
},
|
|
7957
|
+
{
|
|
7958
|
+
name: "agent-none-completion-real",
|
|
7959
|
+
order: "agent-first",
|
|
7960
|
+
completion: "completion",
|
|
7961
|
+
agent: null,
|
|
7962
|
+
expected: "completion",
|
|
7963
|
+
},
|
|
7964
|
+
{
|
|
7965
|
+
name: "agent-none-completion-blank",
|
|
7966
|
+
order: "agent-first",
|
|
7967
|
+
completion: " ",
|
|
7968
|
+
agent: null,
|
|
7969
|
+
expected: undefined,
|
|
7970
|
+
},
|
|
7971
|
+
] as const)("reconciles contract-valid skill completion and agent_end ordering ($name)", async testCase => {
|
|
7972
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
|
|
7973
|
+
const completion = Promise.withResolvers<unknown>();
|
|
7974
|
+
const completionReconciled = Promise.withResolvers<void>();
|
|
7975
|
+
try {
|
|
7976
|
+
const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
|
|
7977
|
+
onInvocationCompletionReconciled: kind => {
|
|
7978
|
+
if (kind === "skill") completionReconciled.resolve();
|
|
7979
|
+
},
|
|
7980
|
+
invokeSkill: async (_name, _args, options) => {
|
|
7981
|
+
await options?.onPreflightAcceptCommit?.();
|
|
7982
|
+
return await completion.promise;
|
|
7983
|
+
},
|
|
7984
|
+
});
|
|
7985
|
+
const accepted = await harness.control("skill.invoke", { name: "ralplan" });
|
|
7986
|
+
const selector = {
|
|
7987
|
+
kind: "skill" as const,
|
|
7988
|
+
commandId: accepted.result?.commandId,
|
|
7989
|
+
turnId: accepted.result?.turnId,
|
|
7990
|
+
};
|
|
7991
|
+
await harness.emit("agent_start");
|
|
7992
|
+
const emitAgentEnd = () =>
|
|
7993
|
+
harness.emit("agent_end", {
|
|
7994
|
+
messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
|
|
5980
7995
|
});
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
harness.emit("agent_end", {
|
|
5990
|
-
messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
|
|
5991
|
-
});
|
|
5992
|
-
if (testCase.order === "completion-first") {
|
|
5993
|
-
completion.resolve(testCase.completion);
|
|
5994
|
-
await completionReconciled.promise;
|
|
5995
|
-
await emitAgentEnd();
|
|
5996
|
-
} else {
|
|
5997
|
-
await emitAgentEnd();
|
|
5998
|
-
completion.resolve(testCase.completion);
|
|
5999
|
-
await completionReconciled.promise;
|
|
6000
|
-
}
|
|
6001
|
-
const result = await harness.query("turn.result", selector);
|
|
6002
|
-
const content = (result.result as { content?: { text?: string } } | undefined)?.content;
|
|
6003
|
-
expect(content?.text).toBe(testCase.expected);
|
|
6004
|
-
await harness.stop();
|
|
6005
|
-
} finally {
|
|
6006
|
-
completion.resolve(undefined);
|
|
6007
|
-
await rm(cwd, { recursive: true, force: true });
|
|
7996
|
+
if (testCase.order === "completion-first") {
|
|
7997
|
+
completion.resolve(testCase.completion);
|
|
7998
|
+
await completionReconciled.promise;
|
|
7999
|
+
await emitAgentEnd();
|
|
8000
|
+
} else {
|
|
8001
|
+
await emitAgentEnd();
|
|
8002
|
+
completion.resolve(testCase.completion);
|
|
8003
|
+
await completionReconciled.promise;
|
|
6008
8004
|
}
|
|
8005
|
+
const result = await harness.query("turn.result", selector);
|
|
8006
|
+
const content = (result.result as { content?: { text?: string } } | undefined)?.content;
|
|
8007
|
+
expect(content?.text).toBe(testCase.expected);
|
|
8008
|
+
await harness.stop();
|
|
8009
|
+
} finally {
|
|
8010
|
+
completion.resolve(undefined);
|
|
8011
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6009
8012
|
}
|
|
6010
8013
|
}, 30_000);
|
|
6011
8014
|
test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
|
|
@@ -6016,6 +8019,11 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
6016
8019
|
sendUserMessage: async (_content, options) => {
|
|
6017
8020
|
if (options?.deliverAs === "followUp") {
|
|
6018
8021
|
await options?.onPreflightAcceptCommit?.();
|
|
8022
|
+
options.preflightSignal?.addEventListener(
|
|
8023
|
+
"abort",
|
|
8024
|
+
() => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
|
|
8025
|
+
{ once: true },
|
|
8026
|
+
);
|
|
6019
8027
|
// #queueFollowUp resolves immediately; the turn has not run yet.
|
|
6020
8028
|
return;
|
|
6021
8029
|
}
|
|
@@ -6078,6 +8086,11 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
6078
8086
|
sendUserMessage: async (_content, options) => {
|
|
6079
8087
|
// Session is streaming: sendUserMessage diverts to #queueSteer and resolves.
|
|
6080
8088
|
await options?.onPreflightAcceptCommit?.();
|
|
8089
|
+
options?.preflightSignal?.addEventListener(
|
|
8090
|
+
"abort",
|
|
8091
|
+
() => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
|
|
8092
|
+
{ once: true },
|
|
8093
|
+
);
|
|
6081
8094
|
},
|
|
6082
8095
|
isIdle: () => false,
|
|
6083
8096
|
});
|
|
@@ -6102,6 +8115,11 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
6102
8115
|
// The session is streaming when the submission starts (divert to steer).
|
|
6103
8116
|
// During accept()->persist(), the prior turn unwinds and isIdle flips to true.
|
|
6104
8117
|
await options?.onPreflightAcceptCommit?.();
|
|
8118
|
+
options?.preflightSignal?.addEventListener(
|
|
8119
|
+
"abort",
|
|
8120
|
+
() => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
|
|
8121
|
+
{ once: true },
|
|
8122
|
+
);
|
|
6105
8123
|
idle = true;
|
|
6106
8124
|
},
|
|
6107
8125
|
isIdle: () => idle,
|
|
@@ -6928,10 +8946,16 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
6928
8946
|
promptDeadlineMs = 100;
|
|
6929
8947
|
const queued = await harness.control("turn.follow_up", { text: "queued prompt" });
|
|
6930
8948
|
expect(queued.ok).toBe(true);
|
|
6931
|
-
promoted?.({ startsOwnRun: true });
|
|
6932
|
-
promptDeadlineMs = 600_000;
|
|
6933
8949
|
const activeIds = { commandId: active.result?.commandId, turnId: active.result?.turnId };
|
|
6934
8950
|
const queuedIds = { commandId: queued.result?.commandId, turnId: queued.result?.turnId };
|
|
8951
|
+
// The short lease belongs to an unconsumed queue entry, not to the root
|
|
8952
|
+
// run: it stays accepted past two lease periods without borrowing root abort.
|
|
8953
|
+
await Bun.sleep(250);
|
|
8954
|
+
expect(await harness.query("turn.prompt_status", queuedIds)).toMatchObject({ result: { status: "accepted" } });
|
|
8955
|
+
expect(abortCalls).toBe(0);
|
|
8956
|
+
expect(correlatedFrames(harness, queuedIds).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
|
|
8957
|
+
promoted?.({ startsOwnRun: true });
|
|
8958
|
+
promptDeadlineMs = 600_000;
|
|
6935
8959
|
await Bun.sleep(150);
|
|
6936
8960
|
expect((await harness.query("turn.prompt_status", queuedIds)).result?.status).toMatch(/accepted|in_flight/);
|
|
6937
8961
|
expect(await harness.query("turn.prompt_status", activeIds)).toMatchObject({
|
|
@@ -7711,7 +9735,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
7711
9735
|
sessionId: transport.sessionId,
|
|
7712
9736
|
});
|
|
7713
9737
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
7714
|
-
|
|
9738
|
+
createTestRuntimeExtension(api, {
|
|
7715
9739
|
agentDir: cwd,
|
|
7716
9740
|
createTransport: async () => transport,
|
|
7717
9741
|
terminalAbortSeams: {
|
|
@@ -7774,6 +9798,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
7774
9798
|
expect((await statusOf(idsB, "inrun-status-b")).result?.status).toBe("terminal_ok");
|
|
7775
9799
|
// A separate later turn starts: the consumed correlation must NOT be
|
|
7776
9800
|
// drained into it, so conn-b owns nothing and its abort is refused.
|
|
9801
|
+
idle = true;
|
|
7777
9802
|
transport.feed("conn-c", {
|
|
7778
9803
|
type: "control_request",
|
|
7779
9804
|
id: "inrun-c",
|
|
@@ -8784,7 +10809,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8784
10809
|
sessionId: transport.sessionId,
|
|
8785
10810
|
});
|
|
8786
10811
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
8787
|
-
|
|
10812
|
+
createTestRuntimeExtension(api, {
|
|
8788
10813
|
agentDir: cwd,
|
|
8789
10814
|
createTransport: async () => transport,
|
|
8790
10815
|
terminalAbortSeams: {
|
|
@@ -8925,7 +10950,7 @@ test("SDK-only host never advances a finalized uncertain row for a mismatched re
|
|
|
8925
10950
|
sessionId: transport.sessionId,
|
|
8926
10951
|
});
|
|
8927
10952
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
8928
|
-
|
|
10953
|
+
createTestRuntimeExtension(api, {
|
|
8929
10954
|
agentDir: cwd,
|
|
8930
10955
|
createTransport: async () => transport,
|
|
8931
10956
|
terminalAbortSeams: {
|
|
@@ -9018,7 +11043,7 @@ test("SDK-only host FIFO-expires tombstones instead of failing the finalization
|
|
|
9018
11043
|
sessionFile: path.join(cwd, "session.json"),
|
|
9019
11044
|
sessionId: transport.sessionId,
|
|
9020
11045
|
});
|
|
9021
|
-
|
|
11046
|
+
createTestRuntimeExtension(api, {
|
|
9022
11047
|
agentDir: cwd,
|
|
9023
11048
|
createTransport: async () => transport,
|
|
9024
11049
|
terminalAbortSeams: {
|
|
@@ -9094,7 +11119,7 @@ test("SDK-only host cancels only the aborting requester's preflight while anothe
|
|
|
9094
11119
|
sessionFile: path.join(cwd, "session.json"),
|
|
9095
11120
|
sessionId: transport.sessionId,
|
|
9096
11121
|
});
|
|
9097
|
-
|
|
11122
|
+
createTestRuntimeExtension(api, {
|
|
9098
11123
|
agentDir: cwd,
|
|
9099
11124
|
createTransport: async () => transport,
|
|
9100
11125
|
terminalAbortSeams: {
|
|
@@ -9184,7 +11209,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
|
|
|
9184
11209
|
sessionFile: path.join(cwd, "session.json"),
|
|
9185
11210
|
sessionId: transport.sessionId,
|
|
9186
11211
|
});
|
|
9187
|
-
|
|
11212
|
+
createTestRuntimeExtension(api, {
|
|
9188
11213
|
agentDir: cwd,
|
|
9189
11214
|
createTransport: async () => transport,
|
|
9190
11215
|
terminalAbortSeams: {
|
|
@@ -9257,7 +11282,7 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
|
|
|
9257
11282
|
});
|
|
9258
11283
|
let captureCalls = 0;
|
|
9259
11284
|
let discardCalls = 0;
|
|
9260
|
-
|
|
11285
|
+
createTestRuntimeExtension(api, {
|
|
9261
11286
|
agentDir: cwd,
|
|
9262
11287
|
createTransport: async () => transport,
|
|
9263
11288
|
terminalAbortSeams: {
|
|
@@ -9372,6 +11397,318 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
|
|
|
9372
11397
|
}
|
|
9373
11398
|
});
|
|
9374
11399
|
|
|
11400
|
+
test.each([
|
|
11401
|
+
"hold",
|
|
11402
|
+
"fail",
|
|
11403
|
+
"removed-before-shutdown",
|
|
11404
|
+
"timeout",
|
|
11405
|
+
"failed-stop",
|
|
11406
|
+
] as const)("SDK-only shutdown joins queued terminal publication (%s)", async mode => {
|
|
11407
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-queued-shutdown-"));
|
|
11408
|
+
let removeQueued: (() => void) | undefined;
|
|
11409
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
11410
|
+
let queued = false;
|
|
11411
|
+
let queueSignal: AbortSignal | undefined;
|
|
11412
|
+
const api = {
|
|
11413
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
11414
|
+
handlers.set(event, handler);
|
|
11415
|
+
},
|
|
11416
|
+
sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
|
|
11417
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
11418
|
+
queued = true;
|
|
11419
|
+
queueSignal = options?.preflightSignal;
|
|
11420
|
+
removeQueued = () => {
|
|
11421
|
+
if (!queued) return;
|
|
11422
|
+
queued = false;
|
|
11423
|
+
options?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
|
|
11424
|
+
};
|
|
11425
|
+
queueSignal?.addEventListener("abort", removeQueued, { once: true });
|
|
11426
|
+
options?.onPreflightAccepted?.();
|
|
11427
|
+
return {};
|
|
11428
|
+
}),
|
|
11429
|
+
} as unknown as ExtensionAPI;
|
|
11430
|
+
const transport = memoryTransport();
|
|
11431
|
+
let transportStops = 0;
|
|
11432
|
+
const originalStop = transport.stop.bind(transport);
|
|
11433
|
+
const stopSpy = spyOn(transport, "stop").mockImplementation(async () => {
|
|
11434
|
+
transportStops++;
|
|
11435
|
+
if (mode === "failed-stop" && transportStops === 1) throw new Error("Injected first transport stop failure");
|
|
11436
|
+
await originalStop();
|
|
11437
|
+
});
|
|
11438
|
+
const sessionFile = path.join(cwd, "session.json");
|
|
11439
|
+
const store = createReconciliationStore({ sessionFile, sessionId: transport.sessionId });
|
|
11440
|
+
let transportCreations = 0;
|
|
11441
|
+
createSdkSessionRuntimeExtension(api, {
|
|
11442
|
+
agentDir: cwd,
|
|
11443
|
+
createTransport: async () => {
|
|
11444
|
+
transportCreations++;
|
|
11445
|
+
return transport;
|
|
11446
|
+
},
|
|
11447
|
+
terminalAbortSeams: {
|
|
11448
|
+
getReconciliationStore: () => store,
|
|
11449
|
+
getTerminalTurnEpoch: () => 7,
|
|
11450
|
+
getActivePromptHandle: () => "unrelated-handle",
|
|
11451
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
11452
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
|
|
11453
|
+
},
|
|
11454
|
+
});
|
|
11455
|
+
const ctx = { ...extensionContext(transport.sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
|
|
11456
|
+
const started = Promise.withResolvers<void>();
|
|
11457
|
+
const release = Promise.withResolvers<void>();
|
|
11458
|
+
const target = reconciliationStorePath(sessionFile, transport.sessionId);
|
|
11459
|
+
const originalRename = fsPromises.rename.bind(fsPromises);
|
|
11460
|
+
let commandId: string | undefined;
|
|
11461
|
+
let armed = false;
|
|
11462
|
+
let interrupted = false;
|
|
11463
|
+
const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
|
|
11464
|
+
if (armed && String(to) === target) {
|
|
11465
|
+
const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
|
|
11466
|
+
if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) {
|
|
11467
|
+
interrupted = true;
|
|
11468
|
+
started.resolve();
|
|
11469
|
+
if (mode === "fail" || mode === "failed-stop")
|
|
11470
|
+
throw Object.assign(new Error("Injected queued terminal EIO"), { code: "EIO" });
|
|
11471
|
+
await release.promise;
|
|
11472
|
+
}
|
|
11473
|
+
}
|
|
11474
|
+
return originalRename(from, to);
|
|
11475
|
+
});
|
|
11476
|
+
const waitFor = async (predicate: () => boolean, label: string): Promise<void> => {
|
|
11477
|
+
const deadline = Date.now() + 15_000;
|
|
11478
|
+
while (!predicate()) {
|
|
11479
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
|
|
11480
|
+
await Bun.sleep(10);
|
|
11481
|
+
}
|
|
11482
|
+
};
|
|
11483
|
+
let shutdown: Promise<void> | undefined;
|
|
11484
|
+
try {
|
|
11485
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
11486
|
+
transport.feed("requester", {
|
|
11487
|
+
type: "control_request",
|
|
11488
|
+
id: "followup",
|
|
11489
|
+
operation: "turn.follow_up",
|
|
11490
|
+
input: { text: "unconsumed followup" },
|
|
11491
|
+
} as SdkFrame);
|
|
11492
|
+
await waitFor(() => transport.sent.some(frame => frame.id === "followup"), "follow-up admission");
|
|
11493
|
+
const accepted = transport.sent.find(frame => frame.id === "followup") as ResponseFrame;
|
|
11494
|
+
expect(accepted.ok).toBe(true);
|
|
11495
|
+
commandId = accepted.result?.commandId;
|
|
11496
|
+
expect(commandId).toBeDefined();
|
|
11497
|
+
await waitFor(() => queued && queueSignal !== undefined, "queued follow-up");
|
|
11498
|
+
armed = true;
|
|
11499
|
+
if (mode === "removed-before-shutdown") {
|
|
11500
|
+
removeQueued?.();
|
|
11501
|
+
await started.promise;
|
|
11502
|
+
}
|
|
11503
|
+
let settled = false;
|
|
11504
|
+
shutdown = Promise.resolve(handlers.get("session_shutdown")?.({}, ctx));
|
|
11505
|
+
void shutdown.then(
|
|
11506
|
+
() => {
|
|
11507
|
+
settled = true;
|
|
11508
|
+
},
|
|
11509
|
+
() => {
|
|
11510
|
+
settled = true;
|
|
11511
|
+
},
|
|
11512
|
+
);
|
|
11513
|
+
await started.promise;
|
|
11514
|
+
expect(interrupted).toBe(true);
|
|
11515
|
+
expect(queueSignal?.aborted).toBe(true);
|
|
11516
|
+
expect(queued).toBe(false);
|
|
11517
|
+
if (mode === "fail" || mode === "timeout" || mode === "failed-stop") {
|
|
11518
|
+
await expect(shutdown).rejects.toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
|
|
11519
|
+
expect(transportStops).toBe(1);
|
|
11520
|
+
if (mode === "fail" || mode === "timeout") {
|
|
11521
|
+
await expect(Promise.resolve(handlers.get("session_start")?.({}, ctx))).rejects.toMatchObject({
|
|
11522
|
+
code: "sdk_reconciliation_teardown_failed",
|
|
11523
|
+
});
|
|
11524
|
+
expect(transportCreations).toBe(1);
|
|
11525
|
+
if (mode === "timeout") {
|
|
11526
|
+
release.resolve();
|
|
11527
|
+
const deadline = Date.now() + 5_000;
|
|
11528
|
+
while (
|
|
11529
|
+
!((await Bun.file(target).json()) as ReconciliationStoreDocument).records.some(
|
|
11530
|
+
record => record.commandId === commandId && record.terminalAt !== undefined,
|
|
11531
|
+
)
|
|
11532
|
+
) {
|
|
11533
|
+
if (Date.now() > deadline) throw new Error("Timed out awaiting original publisher after teardown");
|
|
11534
|
+
await Bun.sleep(10);
|
|
11535
|
+
}
|
|
11536
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
11537
|
+
expect(transportCreations).toBe(2);
|
|
11538
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
11539
|
+
}
|
|
11540
|
+
}
|
|
11541
|
+
if (mode === "failed-stop") {
|
|
11542
|
+
await expect(Promise.resolve(handlers.get("session_shutdown")?.({}, ctx))).rejects.toMatchObject({
|
|
11543
|
+
code: "sdk_reconciliation_teardown_failed",
|
|
11544
|
+
});
|
|
11545
|
+
expect(transportStops).toBe(2);
|
|
11546
|
+
}
|
|
11547
|
+
} else {
|
|
11548
|
+
await Bun.sleep(25);
|
|
11549
|
+
expect(settled).toBe(false);
|
|
11550
|
+
expect(transportStops).toBe(0);
|
|
11551
|
+
transport.feed("requester", {
|
|
11552
|
+
type: "control_request",
|
|
11553
|
+
id: "draining-followup",
|
|
11554
|
+
operation: "turn.follow_up",
|
|
11555
|
+
input: { text: "must not enter during drain" },
|
|
11556
|
+
} as SdkFrame);
|
|
11557
|
+
await waitFor(() => transport.sent.some(frame => frame.id === "draining-followup"), "fenced admission");
|
|
11558
|
+
expect(transport.sent.find(frame => frame.id === "draining-followup")).toMatchObject({
|
|
11559
|
+
ok: false,
|
|
11560
|
+
error: { code: "session_quiescing" },
|
|
11561
|
+
});
|
|
11562
|
+
const pending = (await Bun.file(target).json()) as ReconciliationStoreDocument;
|
|
11563
|
+
expect(pending.records.find(record => record.commandId === commandId)?.terminalAt).toBeUndefined();
|
|
11564
|
+
release.resolve();
|
|
11565
|
+
await shutdown;
|
|
11566
|
+
expect(transportStops).toBe(1);
|
|
11567
|
+
const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
|
|
11568
|
+
expect(durable.records.find(record => record.commandId === commandId)?.terminalAt).toBeDefined();
|
|
11569
|
+
}
|
|
11570
|
+
} finally {
|
|
11571
|
+
release.resolve();
|
|
11572
|
+
await shutdown?.catch(() => undefined);
|
|
11573
|
+
fault.mockRestore();
|
|
11574
|
+
stopSpy.mockRestore();
|
|
11575
|
+
await rm(cwd, { recursive: true, force: true });
|
|
11576
|
+
}
|
|
11577
|
+
});
|
|
11578
|
+
|
|
11579
|
+
test.each([
|
|
11580
|
+
false,
|
|
11581
|
+
true,
|
|
11582
|
+
])("SDK-only queued terminal recovery permits replacement after EIO (early shutdown=%s)", async earlyShutdown => {
|
|
11583
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-recovery-"));
|
|
11584
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
11585
|
+
let transport = memoryTransport();
|
|
11586
|
+
const sessionId = transport.sessionId;
|
|
11587
|
+
const sessionFile = path.join(cwd, `${sessionId}.json`);
|
|
11588
|
+
const store = createReconciliationStore({ sessionFile, sessionId });
|
|
11589
|
+
let creations = 0;
|
|
11590
|
+
let queued = false;
|
|
11591
|
+
let queueSignal: AbortSignal | undefined;
|
|
11592
|
+
const api = {
|
|
11593
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
11594
|
+
handlers.set(event, handler);
|
|
11595
|
+
},
|
|
11596
|
+
sendUserMessage: (_content: string, hooks?: PreflightHooks) =>
|
|
11597
|
+
Promise.resolve(hooks?.onPreflightAcceptCommit?.()).then(() => {
|
|
11598
|
+
queued = true;
|
|
11599
|
+
queueSignal = hooks?.preflightSignal;
|
|
11600
|
+
queueSignal?.addEventListener(
|
|
11601
|
+
"abort",
|
|
11602
|
+
() => {
|
|
11603
|
+
if (!queued) return;
|
|
11604
|
+
queued = false;
|
|
11605
|
+
hooks?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
|
|
11606
|
+
},
|
|
11607
|
+
{ once: true },
|
|
11608
|
+
);
|
|
11609
|
+
hooks?.onPreflightAccepted?.();
|
|
11610
|
+
return {};
|
|
11611
|
+
}),
|
|
11612
|
+
} as unknown as ExtensionAPI;
|
|
11613
|
+
createSdkSessionRuntimeExtension(api, {
|
|
11614
|
+
agentDir: cwd,
|
|
11615
|
+
settings: {
|
|
11616
|
+
get: (key: string) =>
|
|
11617
|
+
key === "sdk.promptDeadlineMs" ? 1_500 : key === "sdk.promptMaxRuntimeMs" ? 15_000 : undefined,
|
|
11618
|
+
} as unknown as Settings,
|
|
11619
|
+
createTransport: async () => {
|
|
11620
|
+
creations++;
|
|
11621
|
+
transport = memoryTransport();
|
|
11622
|
+
return transport;
|
|
11623
|
+
},
|
|
11624
|
+
terminalAbortSeams: {
|
|
11625
|
+
getReconciliationStore: () => store,
|
|
11626
|
+
getTerminalTurnEpoch: () => 7,
|
|
11627
|
+
getActivePromptHandle: () => "unrelated-handle",
|
|
11628
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
11629
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
|
|
11630
|
+
},
|
|
11631
|
+
});
|
|
11632
|
+
const ctx = { ...extensionContext(sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
|
|
11633
|
+
const target = reconciliationStorePath(sessionFile, sessionId);
|
|
11634
|
+
const waitFor = async (predicate: () => boolean | Promise<boolean>, label: string): Promise<void> => {
|
|
11635
|
+
const deadline = Date.now() + 10_000;
|
|
11636
|
+
while (!(await predicate())) {
|
|
11637
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
|
|
11638
|
+
await Bun.sleep(10);
|
|
11639
|
+
}
|
|
11640
|
+
};
|
|
11641
|
+
let requestId = 0;
|
|
11642
|
+
const request = async (frame: Record<string, unknown>): Promise<ResponseFrame> => {
|
|
11643
|
+
const id = `terminal-recovery-${++requestId}`;
|
|
11644
|
+
transport.feed("requester", { ...frame, id } as SdkFrame);
|
|
11645
|
+
await waitFor(() => transport.sent.some(candidate => candidate.id === id), id);
|
|
11646
|
+
return transport.sent.find(candidate => candidate.id === id) as ResponseFrame;
|
|
11647
|
+
};
|
|
11648
|
+
const originalRename = fsPromises.rename.bind(fsPromises);
|
|
11649
|
+
let commandId: string | undefined;
|
|
11650
|
+
let injected = false;
|
|
11651
|
+
const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
|
|
11652
|
+
if (!injected && commandId && String(to) === target) {
|
|
11653
|
+
const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
|
|
11654
|
+
if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) {
|
|
11655
|
+
injected = true;
|
|
11656
|
+
throw Object.assign(new Error("Injected terminal publication failure"), { code: "EIO" });
|
|
11657
|
+
}
|
|
11658
|
+
}
|
|
11659
|
+
return originalRename(from, to);
|
|
11660
|
+
});
|
|
11661
|
+
try {
|
|
11662
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
11663
|
+
const accepted = await request({
|
|
11664
|
+
type: "control_request",
|
|
11665
|
+
operation: "turn.follow_up",
|
|
11666
|
+
input: { text: "queued input" },
|
|
11667
|
+
});
|
|
11668
|
+
expect(accepted.ok).toBe(true);
|
|
11669
|
+
commandId = accepted.result?.commandId;
|
|
11670
|
+
const turnId = accepted.result?.turnId;
|
|
11671
|
+
expect(commandId).toBeDefined();
|
|
11672
|
+
await waitFor(() => queued && queueSignal !== undefined, "queued admission");
|
|
11673
|
+
expect(await request({ type: "control_request", operation: "turn.abort", input: {} })).toMatchObject({
|
|
11674
|
+
ok: true,
|
|
11675
|
+
result: { aborted: false, reason: "queue_terminal_unconfirmed" },
|
|
11676
|
+
});
|
|
11677
|
+
expect(injected).toBe(true);
|
|
11678
|
+
expect(queueSignal?.aborted).toBe(true);
|
|
11679
|
+
expect(queued).toBe(false);
|
|
11680
|
+
fault.mockRestore();
|
|
11681
|
+
const initial = (await Bun.file(target).json()) as ReconciliationStoreDocument;
|
|
11682
|
+
expect(initial.records.find(record => record.commandId === commandId)?.terminalAt).toBeUndefined();
|
|
11683
|
+
if (earlyShutdown)
|
|
11684
|
+
await expect(Promise.resolve(handlers.get("session_shutdown")?.({}, ctx))).rejects.toMatchObject({
|
|
11685
|
+
code: "sdk_reconciliation_teardown_failed",
|
|
11686
|
+
});
|
|
11687
|
+
await waitFor(async () => {
|
|
11688
|
+
const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
|
|
11689
|
+
return durable.records.some(record => record.commandId === commandId && record.terminalAt !== undefined);
|
|
11690
|
+
}, "original-owner terminal recovery");
|
|
11691
|
+
const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
|
|
11692
|
+
expect(durable.records.find(record => record.commandId === commandId)).toMatchObject({
|
|
11693
|
+
status: "failed",
|
|
11694
|
+
error: { code: "cancelled" },
|
|
11695
|
+
});
|
|
11696
|
+
if (!earlyShutdown) await handlers.get("session_shutdown")?.({}, ctx);
|
|
11697
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
11698
|
+
expect(creations).toBe(2);
|
|
11699
|
+
expect(
|
|
11700
|
+
await request({ type: "query_request", query: "turn.result", input: { kind: "prompt", commandId, turnId } }),
|
|
11701
|
+
).toMatchObject({
|
|
11702
|
+
ok: true,
|
|
11703
|
+
result: { status: "failed", error: { code: "cancelled" } },
|
|
11704
|
+
});
|
|
11705
|
+
} finally {
|
|
11706
|
+
fault.mockRestore();
|
|
11707
|
+
await Promise.resolve(handlers.get("session_shutdown")?.({}, ctx)).catch(() => undefined);
|
|
11708
|
+
await rm(cwd, { recursive: true, force: true });
|
|
11709
|
+
}
|
|
11710
|
+
});
|
|
11711
|
+
|
|
9375
11712
|
test("SDK-only host does not assign a follow-up requester ownership until the follow-up actually starts", async () => {
|
|
9376
11713
|
// Review thread P1: a turn.follow_up accepted while ctx.isIdle() is true
|
|
9377
11714
|
// but the follow-up is never promoted (compaction, transcript ending in a
|
|
@@ -9382,23 +11719,30 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
|
|
|
9382
11719
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-followup-stale-"));
|
|
9383
11720
|
const idle = true;
|
|
9384
11721
|
let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
|
|
11722
|
+
let queueSignal: AbortSignal | undefined;
|
|
9385
11723
|
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
9386
11724
|
const api = {
|
|
9387
11725
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
9388
11726
|
handlers.set(event, handler);
|
|
9389
11727
|
},
|
|
9390
|
-
sendUserMessage: (
|
|
9391
|
-
_content: string,
|
|
9392
|
-
options:
|
|
9393
|
-
| {
|
|
9394
|
-
onPreflightAccepted?: () => void;
|
|
9395
|
-
onPreflightAcceptCommit?: () => void;
|
|
9396
|
-
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
9397
|
-
}
|
|
9398
|
-
| undefined,
|
|
9399
|
-
) =>
|
|
11728
|
+
sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
|
|
9400
11729
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
9401
|
-
|
|
11730
|
+
let queued = true;
|
|
11731
|
+
queueSignal = options?.preflightSignal;
|
|
11732
|
+
promoted = promotion => {
|
|
11733
|
+
if (!queued) return;
|
|
11734
|
+
queued = false;
|
|
11735
|
+
options?.onQueuedPromoted?.(promotion);
|
|
11736
|
+
};
|
|
11737
|
+
queueSignal?.addEventListener(
|
|
11738
|
+
"abort",
|
|
11739
|
+
() => {
|
|
11740
|
+
if (!queued) return;
|
|
11741
|
+
queued = false;
|
|
11742
|
+
options?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
|
|
11743
|
+
},
|
|
11744
|
+
{ once: true },
|
|
11745
|
+
);
|
|
9402
11746
|
options?.onPreflightAccepted?.();
|
|
9403
11747
|
return {};
|
|
9404
11748
|
}),
|
|
@@ -9409,7 +11753,7 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
|
|
|
9409
11753
|
sessionId: transport.sessionId,
|
|
9410
11754
|
});
|
|
9411
11755
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
9412
|
-
|
|
11756
|
+
createTestRuntimeExtension(api, {
|
|
9413
11757
|
agentDir: cwd,
|
|
9414
11758
|
createTransport: async () => transport,
|
|
9415
11759
|
terminalAbortSeams: {
|
|
@@ -9465,7 +11809,16 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
|
|
|
9465
11809
|
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
9466
11810
|
});
|
|
9467
11811
|
expect(seamCalls).toHaveLength(0);
|
|
9468
|
-
|
|
11812
|
+
expect(queueSignal?.aborted).toBe(true);
|
|
11813
|
+
// The cancelled input cannot be promoted. A fresh follow-up receives fresh authority.
|
|
11814
|
+
transport.feed("conn-b", {
|
|
11815
|
+
type: "control_request",
|
|
11816
|
+
id: "followup-b-next",
|
|
11817
|
+
operation: "turn.follow_up",
|
|
11818
|
+
input: { text: "fresh followup" },
|
|
11819
|
+
} as SdkFrame);
|
|
11820
|
+
await waitResponse("followup-b-next");
|
|
11821
|
+
expect(queueSignal?.aborted).toBe(false);
|
|
9469
11822
|
promoted!({ startsOwnRun: true });
|
|
9470
11823
|
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
9471
11824
|
transport.feed("conn-b", {
|
|
@@ -9522,7 +11875,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
|
|
|
9522
11875
|
sessionId: transport.sessionId,
|
|
9523
11876
|
});
|
|
9524
11877
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
9525
|
-
|
|
11878
|
+
createTestRuntimeExtension(api, {
|
|
9526
11879
|
agentDir: cwd,
|
|
9527
11880
|
createTransport: async () => transport,
|
|
9528
11881
|
terminalAbortSeams: {
|
|
@@ -9647,7 +12000,7 @@ test("SDK-only host rebinds the steering snapshot when the requester's turn wins
|
|
|
9647
12000
|
let ownerReads = 0;
|
|
9648
12001
|
let rebindCalls = 0;
|
|
9649
12002
|
const settledOptions: Array<{ scope?: string; steeringSnapshotToken?: number }> = [];
|
|
9650
|
-
|
|
12003
|
+
createTestRuntimeExtension(api, {
|
|
9651
12004
|
agentDir: cwd,
|
|
9652
12005
|
createTransport: async () => transport,
|
|
9653
12006
|
terminalAbortSeams: {
|