@gajae-code/coding-agent 0.18.6 → 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 +318 -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 +6 -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/settings.d.ts +4 -0
- 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 +8 -3
- 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/elicitation.d.ts +12 -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/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -6
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- 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/bus/telegram-daemon.d.ts +3 -2
- package/dist/types/sdk/client/client.d.ts +2 -0
- package/dist/types/sdk/host/host.d.ts +6 -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 +9 -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 +36 -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 +19 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +31 -5
- package/dist/types/session/session-retirement.d.ts +71 -0
- package/dist/types/session/session-storage.d.ts +62 -48
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -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/resident-memory-bench.ts +174 -20
- package/scripts/verify-managed-empty-stop.ts +28 -0
- package/src/async/job-manager.ts +228 -122
- package/src/capability/index.ts +18 -0
- package/src/capability/mcp.ts +2 -0
- package/src/capability/skill.ts +2 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/auth-broker-cli.ts +1 -1
- package/src/cli/config-cli.ts +8 -18
- 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/cli-main.ts +7 -8
- package/src/commands/deep-interview.ts +47 -8
- package/src/commands/sdk.ts +140 -29
- 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/settings.ts +44 -4
- 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 +168 -23
- package/src/discovery/mcp-json.ts +6 -0
- package/src/discovery/ssh.ts +9 -4
- 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 -8
- 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/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +19 -18
- 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 +163 -7
- 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/client.ts +13 -4
- package/src/runtime-mcp/config-writer.ts +10 -6
- package/src/runtime-mcp/config.ts +1 -0
- package/src/runtime-mcp/elicitation.ts +142 -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 +83 -7
- package/src/sdk/broker/ensure.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +820 -73
- package/src/sdk/broker/session-index.ts +191 -81
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/bus/chat-daemon-cli.ts +38 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +241 -46
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
- package/src/sdk/bus/telegram-daemon.ts +4 -2
- package/src/sdk/cli/session-cli.ts +17 -3
- package/src/sdk/client/client.ts +28 -8
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/query/handlers.ts +9 -1
- package/src/sdk/host/session-runtime.test.ts +2805 -411
- package/src/sdk/host/session-runtime.ts +428 -111
- 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/mcp/server.ts +15 -3
- 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 +94 -14
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +81 -38
- package/src/session/agent-session.ts +674 -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 +482 -61
- package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
- package/src/session/internal/task-artifact-owner-access.ts +245 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
- package/src/session/session-manager.ts +939 -133
- package/src/session/session-retirement.ts +714 -0
- package/src/session/session-storage.ts +418 -174
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +641 -0
- package/src/session/task-artifact-owner-retirement.ts +477 -0
- package/src/session/task-artifact-owner.ts +289 -0
- 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,13 +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 } 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";
|
|
14
|
+
import { createTestSession } from "../../../test/utilities";
|
|
8
15
|
import { AsyncJobManager } from "../../async";
|
|
9
|
-
import
|
|
16
|
+
import { ModelRegistry } from "../../config/model-registry";
|
|
17
|
+
import { Settings } from "../../config/settings";
|
|
10
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";
|
|
11
23
|
import {
|
|
12
24
|
registerOwnedRegistration,
|
|
13
25
|
resetTerminalAbortRegistriesForTests,
|
|
@@ -15,14 +27,21 @@ import {
|
|
|
15
27
|
unregisterOwnedRegistration,
|
|
16
28
|
} from "../../session/terminal-abort";
|
|
17
29
|
import { Broker } from "../broker/broker";
|
|
30
|
+
import type { BrokerDiscovery } from "../broker/discovery";
|
|
31
|
+
import { SessionIndex, type SessionIndexEvent } from "../broker/session-index";
|
|
18
32
|
import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
|
|
19
33
|
import { createPromptReconciliation } from "../bus/prompt-reconciliation";
|
|
20
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
createReconciliationStore,
|
|
36
|
+
type ReconciliationStoreDocument,
|
|
37
|
+
reconciliationStorePath,
|
|
38
|
+
} from "../bus/reconciliation-store";
|
|
21
39
|
import { PromptDeadlineManager } from "../prompt-deadline-manager";
|
|
22
40
|
import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
|
|
23
41
|
import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
|
|
24
42
|
import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
|
|
25
43
|
import {
|
|
44
|
+
type CreateSdkSessionRuntimeOptions,
|
|
26
45
|
createInvocationReconciliation,
|
|
27
46
|
createSdkSessionRuntimeExtension,
|
|
28
47
|
createSdkSurfaceFactory,
|
|
@@ -103,7 +122,46 @@ test("runtime capabilities preserve explicit primary control surface", () => {
|
|
|
103
122
|
expect(createSdkCapabilities(policy, false, "cli")).toMatchObject({ primaryControlSurface: "cli" });
|
|
104
123
|
});
|
|
105
124
|
|
|
106
|
-
|
|
125
|
+
test("thinking.set uses the control setter and reports the applied session level", async () => {
|
|
126
|
+
const sessionContext = await createTestSession({ inMemory: true });
|
|
127
|
+
const { session, tempDir: cwd, cleanup } = sessionContext;
|
|
128
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
129
|
+
const api = {
|
|
130
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
131
|
+
handlers.set(event, handler);
|
|
132
|
+
},
|
|
133
|
+
sendUserMessage: async () => {},
|
|
134
|
+
setThinkingLevel: () => {
|
|
135
|
+
throw new Error("thinking.set must not use the legacy setter");
|
|
136
|
+
},
|
|
137
|
+
setThinkingLevelForControl: async (level: ThinkingLevel, persist: boolean) => {
|
|
138
|
+
expect(persist).toBe(false);
|
|
139
|
+
await session.setThinkingLevelForControl(level, persist);
|
|
140
|
+
},
|
|
141
|
+
} as unknown as ExtensionAPI;
|
|
142
|
+
const response = Promise.withResolvers<SdkFrame>();
|
|
143
|
+
const transport = memoryTransport(frame => {
|
|
144
|
+
if (frame.type === "control_response" && frame.id === "thinking-set") response.resolve(frame);
|
|
145
|
+
});
|
|
146
|
+
createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
147
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
148
|
+
try {
|
|
149
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
150
|
+
transport.feed("client", {
|
|
151
|
+
type: "control_request",
|
|
152
|
+
id: "thinking-set",
|
|
153
|
+
operation: "thinking.set",
|
|
154
|
+
input: { level: ThinkingLevel.Medium },
|
|
155
|
+
} as SdkFrame);
|
|
156
|
+
await response.promise;
|
|
157
|
+
expect(session.thinkingLevel).toBe(ThinkingLevel.Medium);
|
|
158
|
+
} finally {
|
|
159
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
160
|
+
await cleanup();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function memoryTransport(onSend?: (frame: SdkFrame) => void): SessionSdkTransport & {
|
|
107
165
|
feed(connectionId: string, frame: SdkFrame): void;
|
|
108
166
|
readonly sent: SdkFrame[];
|
|
109
167
|
readonly broadcasts: SdkFrame[];
|
|
@@ -126,6 +184,7 @@ function memoryTransport(): SessionSdkTransport & {
|
|
|
126
184
|
},
|
|
127
185
|
sendFrame(_connectionId, frame) {
|
|
128
186
|
sent.push(frame);
|
|
187
|
+
onSend?.(frame);
|
|
129
188
|
},
|
|
130
189
|
start: async () => {
|
|
131
190
|
started = true;
|
|
@@ -1652,7 +1711,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1652
1711
|
sendUserMessage: async () => {},
|
|
1653
1712
|
} as unknown as ExtensionAPI;
|
|
1654
1713
|
const transport = memoryTransport();
|
|
1655
|
-
|
|
1714
|
+
createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
1656
1715
|
const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
|
|
1657
1716
|
const ctx = {
|
|
1658
1717
|
...extensionContext(transport.sessionId, cwd),
|
|
@@ -1708,7 +1767,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1708
1767
|
}
|
|
1709
1768
|
broadcastFrame?.(frame);
|
|
1710
1769
|
};
|
|
1711
|
-
|
|
1770
|
+
createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
1712
1771
|
const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
|
|
1713
1772
|
const ctx = {
|
|
1714
1773
|
...extensionContext(transport.sessionId, cwd),
|
|
@@ -1769,7 +1828,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1769
1828
|
let activeHandle: string | undefined = "exact-run-handle";
|
|
1770
1829
|
let activeEpoch: number | undefined = 7;
|
|
1771
1830
|
let captureCalls = 0;
|
|
1772
|
-
|
|
1831
|
+
createTestRuntimeExtension(api, {
|
|
1773
1832
|
agentDir: cwd,
|
|
1774
1833
|
createTransport: async () => transport,
|
|
1775
1834
|
onSdkRequest: undefined,
|
|
@@ -1968,7 +2027,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1968
2027
|
});
|
|
1969
2028
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1970
2029
|
let captureCalls = 0;
|
|
1971
|
-
|
|
2030
|
+
createTestRuntimeExtension(api, {
|
|
1972
2031
|
agentDir: cwd,
|
|
1973
2032
|
createTransport: async () => transport,
|
|
1974
2033
|
terminalAbortSeams: {
|
|
@@ -2065,7 +2124,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2065
2124
|
// exactly the window where writeNoEffect awaits the store.
|
|
2066
2125
|
let promptReads = 0;
|
|
2067
2126
|
let captureCalls = 0;
|
|
2068
|
-
|
|
2127
|
+
createTestRuntimeExtension(api, {
|
|
2069
2128
|
agentDir: cwd,
|
|
2070
2129
|
createTransport: async () => transport,
|
|
2071
2130
|
terminalAbortSeams: {
|
|
@@ -2150,7 +2209,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2150
2209
|
});
|
|
2151
2210
|
const seamCalls: string[] = [];
|
|
2152
2211
|
let promptReads = 0;
|
|
2153
|
-
|
|
2212
|
+
createTestRuntimeExtension(api, {
|
|
2154
2213
|
agentDir: cwd,
|
|
2155
2214
|
createTransport: async () => transport,
|
|
2156
2215
|
terminalAbortSeams: {
|
|
@@ -2220,7 +2279,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2220
2279
|
// (review thread P1).
|
|
2221
2280
|
let reads = 0;
|
|
2222
2281
|
let captureCalls = 0;
|
|
2223
|
-
|
|
2282
|
+
createTestRuntimeExtension(api, {
|
|
2224
2283
|
agentDir: cwd,
|
|
2225
2284
|
createTransport: async () => transport,
|
|
2226
2285
|
terminalAbortSeams: {
|
|
@@ -2313,7 +2372,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2313
2372
|
sessionId: transport.sessionId,
|
|
2314
2373
|
});
|
|
2315
2374
|
let captureCalls = 0;
|
|
2316
|
-
|
|
2375
|
+
createTestRuntimeExtension(api, {
|
|
2317
2376
|
agentDir: cwd,
|
|
2318
2377
|
createTransport: async () => transport,
|
|
2319
2378
|
terminalAbortSeams: {
|
|
@@ -2380,7 +2439,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2380
2439
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2381
2440
|
let seamCount = 0;
|
|
2382
2441
|
let captureCalls = 0;
|
|
2383
|
-
|
|
2442
|
+
createTestRuntimeExtension(api, {
|
|
2384
2443
|
agentDir: cwd,
|
|
2385
2444
|
createTransport: async () => transport,
|
|
2386
2445
|
terminalAbortSeams: {
|
|
@@ -2489,7 +2548,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2489
2548
|
let activeHandle = "predecessor";
|
|
2490
2549
|
let abortCalls = 0;
|
|
2491
2550
|
const abortRelease = Promise.withResolvers<void>();
|
|
2492
|
-
|
|
2551
|
+
createTestRuntimeExtension(api, {
|
|
2493
2552
|
agentDir: cwd,
|
|
2494
2553
|
createTransport: async () => transport,
|
|
2495
2554
|
terminalAbortSeams: {
|
|
@@ -2577,7 +2636,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2577
2636
|
sessionFile: path.join(cwd, "session.json"),
|
|
2578
2637
|
sessionId: transport.sessionId,
|
|
2579
2638
|
});
|
|
2580
|
-
|
|
2639
|
+
createTestRuntimeExtension(api, {
|
|
2581
2640
|
agentDir: cwd,
|
|
2582
2641
|
createTransport: async () => transport,
|
|
2583
2642
|
terminalAbortSeams: {
|
|
@@ -2624,24 +2683,38 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2624
2683
|
}
|
|
2625
2684
|
});
|
|
2626
2685
|
|
|
2627
|
-
test(
|
|
2686
|
+
test.each([
|
|
2687
|
+
"ordinary",
|
|
2688
|
+
"terminal",
|
|
2689
|
+
] as const)("SDK-only %s abort cancels only its admitted preflight snapshot", async mode => {
|
|
2628
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;
|
|
2629
2694
|
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2630
2695
|
const api = {
|
|
2631
2696
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2632
2697
|
handlers.set(event, handler);
|
|
2633
2698
|
},
|
|
2634
|
-
//
|
|
2635
|
-
//
|
|
2636
|
-
|
|
2637
|
-
|
|
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
|
+
},
|
|
2638
2711
|
} as unknown as ExtensionAPI;
|
|
2639
2712
|
const transport = memoryTransport();
|
|
2640
2713
|
const reconciliationStore = createReconciliationStore({
|
|
2641
2714
|
sessionFile: path.join(cwd, "session.json"),
|
|
2642
2715
|
sessionId: transport.sessionId,
|
|
2643
2716
|
});
|
|
2644
|
-
|
|
2717
|
+
createTestRuntimeExtension(api, {
|
|
2645
2718
|
agentDir: cwd,
|
|
2646
2719
|
createTransport: async () => transport,
|
|
2647
2720
|
terminalAbortSeams: {
|
|
@@ -2656,7 +2729,12 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2656
2729
|
},
|
|
2657
2730
|
},
|
|
2658
2731
|
});
|
|
2659
|
-
const ctx =
|
|
2732
|
+
const ctx = {
|
|
2733
|
+
...extensionContext(transport.sessionId, cwd),
|
|
2734
|
+
abort: async () => {
|
|
2735
|
+
rootAbortCalls++;
|
|
2736
|
+
},
|
|
2737
|
+
};
|
|
2660
2738
|
try {
|
|
2661
2739
|
await handlers.get("session_start")?.({}, ctx);
|
|
2662
2740
|
const prompt = (id: string) =>
|
|
@@ -2667,14 +2745,22 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2667
2745
|
input: { text: `hold-${id}`, images: [] },
|
|
2668
2746
|
} as SdkFrame);
|
|
2669
2747
|
prompt("pre-1");
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
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
|
+
}
|
|
2673
2759
|
transport.feed("conn-a", {
|
|
2674
2760
|
type: "control_request",
|
|
2675
2761
|
id: "pre-abort",
|
|
2676
2762
|
operation: "turn.abort",
|
|
2677
|
-
input: { mode: "terminal" },
|
|
2763
|
+
input: mode === "terminal" ? { mode: "terminal" } : {},
|
|
2678
2764
|
idempotencyKey: "pre-snapshot-key",
|
|
2679
2765
|
} as SdkFrame);
|
|
2680
2766
|
// A successor prompt pipelined by the SAME connection while the abort
|
|
@@ -2684,12 +2770,30 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2684
2770
|
const deadline = Date.now() + 15_000;
|
|
2685
2771
|
while (
|
|
2686
2772
|
!transport.sent.some(frame => frame.id === "pre-abort" && frame.type === "control_response") ||
|
|
2687
|
-
!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")
|
|
2688
2775
|
) {
|
|
2689
2776
|
if (Date.now() > deadline)
|
|
2690
2777
|
throw new Error("Timed out waiting for the abort and admitted-preflight responses");
|
|
2691
2778
|
await Bun.sleep(20);
|
|
2692
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
|
+
});
|
|
2693
2797
|
// The admitted preflight is cancelled by the abort.
|
|
2694
2798
|
expect(transport.sent.find(frame => frame.id === "pre-1")).toMatchObject({
|
|
2695
2799
|
ok: false,
|
|
@@ -2697,7 +2801,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2697
2801
|
});
|
|
2698
2802
|
expect(transport.sent.find(frame => frame.id === "pre-abort")).toMatchObject({
|
|
2699
2803
|
ok: true,
|
|
2700
|
-
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
2804
|
+
result: expect.objectContaining(mode === "terminal" ? { turn: "no_active_turn" } : { aborted: true }),
|
|
2701
2805
|
});
|
|
2702
2806
|
// The successor preflight was NOT part of the abort's snapshot: it is
|
|
2703
2807
|
// neither cancelled nor failed, so no control response is emitted for it.
|
|
@@ -2722,7 +2826,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2722
2826
|
sessionId: transport.sessionId,
|
|
2723
2827
|
});
|
|
2724
2828
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2725
|
-
|
|
2829
|
+
createTestRuntimeExtension(api, {
|
|
2726
2830
|
agentDir: cwd,
|
|
2727
2831
|
createTransport: async () => transport,
|
|
2728
2832
|
terminalAbortSeams: {
|
|
@@ -2840,7 +2944,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2840
2944
|
sessionId: transport.sessionId,
|
|
2841
2945
|
});
|
|
2842
2946
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2843
|
-
|
|
2947
|
+
createTestRuntimeExtension(api, {
|
|
2844
2948
|
agentDir: cwd,
|
|
2845
2949
|
createTransport: async () => transport,
|
|
2846
2950
|
terminalAbortSeams: {
|
|
@@ -2931,12 +3035,10 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2931
3035
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2932
3036
|
handlers.set(event, handler);
|
|
2933
3037
|
},
|
|
2934
|
-
sendUserMessage: (
|
|
2935
|
-
_content: string,
|
|
2936
|
-
options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
|
|
2937
|
-
) =>
|
|
3038
|
+
sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
|
|
2938
3039
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
2939
3040
|
options?.onPreflightAccepted?.();
|
|
3041
|
+
if (!idle) options?.onQueuedPromoted?.({ startsOwnRun: false });
|
|
2940
3042
|
return {};
|
|
2941
3043
|
}),
|
|
2942
3044
|
} as unknown as ExtensionAPI;
|
|
@@ -2947,7 +3049,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2947
3049
|
});
|
|
2948
3050
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2949
3051
|
let idle = true;
|
|
2950
|
-
|
|
3052
|
+
createTestRuntimeExtension(api, {
|
|
2951
3053
|
agentDir: cwd,
|
|
2952
3054
|
createTransport: async () => transport,
|
|
2953
3055
|
terminalAbortSeams: {
|
|
@@ -3026,11 +3128,18 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3026
3128
|
handlers.set(event, handler);
|
|
3027
3129
|
},
|
|
3028
3130
|
sendUserMessage: (
|
|
3029
|
-
|
|
3030
|
-
options:
|
|
3131
|
+
content: string,
|
|
3132
|
+
options:
|
|
3133
|
+
| {
|
|
3134
|
+
onPreflightAccepted?: () => void;
|
|
3135
|
+
onPreflightAcceptCommit?: () => void;
|
|
3136
|
+
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
3137
|
+
}
|
|
3138
|
+
| undefined,
|
|
3031
3139
|
) =>
|
|
3032
3140
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
3033
3141
|
options?.onPreflightAccepted?.();
|
|
3142
|
+
if (content === "follow up b") options?.onQueuedPromoted?.({ startsOwnRun: false });
|
|
3034
3143
|
return {};
|
|
3035
3144
|
}),
|
|
3036
3145
|
} as unknown as ExtensionAPI;
|
|
@@ -3041,7 +3150,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3041
3150
|
});
|
|
3042
3151
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3043
3152
|
let idle = true;
|
|
3044
|
-
|
|
3153
|
+
createTestRuntimeExtension(api, {
|
|
3045
3154
|
agentDir: cwd,
|
|
3046
3155
|
createTransport: async () => transport,
|
|
3047
3156
|
terminalAbortSeams: {
|
|
@@ -3200,7 +3309,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3200
3309
|
sessionId: transport.sessionId,
|
|
3201
3310
|
});
|
|
3202
3311
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3203
|
-
|
|
3312
|
+
createTestRuntimeExtension(api, {
|
|
3204
3313
|
agentDir: cwd,
|
|
3205
3314
|
createTransport: async () => transport,
|
|
3206
3315
|
terminalAbortSeams: {
|
|
@@ -3320,7 +3429,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3320
3429
|
sessionId: transport.sessionId,
|
|
3321
3430
|
});
|
|
3322
3431
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3323
|
-
|
|
3432
|
+
createTestRuntimeExtension(api, {
|
|
3324
3433
|
agentDir: cwd,
|
|
3325
3434
|
createTransport: async () => transport,
|
|
3326
3435
|
terminalAbortSeams: {
|
|
@@ -3382,7 +3491,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3382
3491
|
sessionFile: path.join(cwd, "session.json"),
|
|
3383
3492
|
sessionId: transport.sessionId,
|
|
3384
3493
|
});
|
|
3385
|
-
|
|
3494
|
+
createTestRuntimeExtension(api, {
|
|
3386
3495
|
agentDir: cwd,
|
|
3387
3496
|
createTransport: async () => transport,
|
|
3388
3497
|
terminalAbortSeams: {
|
|
@@ -3473,7 +3582,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3473
3582
|
sessionFile: path.join(cwd, "session.json"),
|
|
3474
3583
|
sessionId: transport.sessionId,
|
|
3475
3584
|
});
|
|
3476
|
-
|
|
3585
|
+
createTestRuntimeExtension(api, {
|
|
3477
3586
|
agentDir: cwd,
|
|
3478
3587
|
createTransport: async () => transport,
|
|
3479
3588
|
terminalAbortSeams: {
|
|
@@ -3565,7 +3674,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3565
3674
|
sessionId: transport.sessionId,
|
|
3566
3675
|
});
|
|
3567
3676
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3568
|
-
|
|
3677
|
+
createTestRuntimeExtension(api, {
|
|
3569
3678
|
agentDir: cwd,
|
|
3570
3679
|
createTransport: async () => transport,
|
|
3571
3680
|
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
@@ -3656,7 +3765,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3656
3765
|
sessionId: transport.sessionId,
|
|
3657
3766
|
});
|
|
3658
3767
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3659
|
-
|
|
3768
|
+
createTestRuntimeExtension(api, {
|
|
3660
3769
|
agentDir: cwd,
|
|
3661
3770
|
createTransport: async () => transport,
|
|
3662
3771
|
terminalAbortSeams: {
|
|
@@ -3752,7 +3861,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3752
3861
|
};
|
|
3753
3862
|
registerOwnedRegistration(registration as never, { isJobTerminal: () => false });
|
|
3754
3863
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3755
|
-
|
|
3864
|
+
createTestRuntimeExtension(api, {
|
|
3756
3865
|
agentDir: cwd,
|
|
3757
3866
|
createTransport: async () => transport,
|
|
3758
3867
|
terminalAbortSeams: {
|
|
@@ -3827,7 +3936,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3827
3936
|
sessionFile: path.join(cwd, "session.json"),
|
|
3828
3937
|
sessionId: transport.sessionId,
|
|
3829
3938
|
});
|
|
3830
|
-
|
|
3939
|
+
createTestRuntimeExtension(api, {
|
|
3831
3940
|
agentDir: cwd,
|
|
3832
3941
|
createTransport: async () => transport,
|
|
3833
3942
|
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
@@ -3888,7 +3997,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3888
3997
|
sessionFile: path.join(cwd, "session.json"),
|
|
3889
3998
|
sessionId: transport.sessionId,
|
|
3890
3999
|
});
|
|
3891
|
-
|
|
4000
|
+
createTestRuntimeExtension(api, {
|
|
3892
4001
|
agentDir: cwd,
|
|
3893
4002
|
createTransport: async () => transport,
|
|
3894
4003
|
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
@@ -3978,7 +4087,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
3978
4087
|
},
|
|
3979
4088
|
} as unknown as ExtensionAPI;
|
|
3980
4089
|
const transports: Array<{ starts: number; stops: number }> = [];
|
|
3981
|
-
|
|
4090
|
+
createTestRuntimeExtension(api, {
|
|
3982
4091
|
agentDir: path.join(cwd, ".gjc", "agent"),
|
|
3983
4092
|
createTransport: async ({ sessionId, stateRoot, token }) => {
|
|
3984
4093
|
const stats = { starts: 0, stops: 0 };
|
|
@@ -4049,8 +4158,12 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4049
4158
|
} as any;
|
|
4050
4159
|
const sessionId = "broker-recovery";
|
|
4051
4160
|
const endpointUrl = "ws://127.0.0.1:1";
|
|
4052
|
-
|
|
4161
|
+
createTestRuntimeExtension(api, {
|
|
4053
4162
|
agentDir,
|
|
4163
|
+
ensureBrokerImpl: async () => {
|
|
4164
|
+
if (!broker) throw Object.assign(new Error("injected unavailable broker"), { code: "acquire_timeout" });
|
|
4165
|
+
return testBrokerDiscovery;
|
|
4166
|
+
},
|
|
4054
4167
|
createTransport: async ({ stateRoot, token }) => ({
|
|
4055
4168
|
sessionId,
|
|
4056
4169
|
stateRoot,
|
|
@@ -4075,13 +4188,19 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4075
4188
|
await chmod(agentDir, 0o700);
|
|
4076
4189
|
broker = new Broker({ agentDir });
|
|
4077
4190
|
await broker.start();
|
|
4191
|
+
// Optional registration now completes independently of the event handler.
|
|
4192
|
+
const deadline = Date.now() + 15_000;
|
|
4078
4193
|
await handlers.get("turn_start")?.({}, context);
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
}
|
|
4084
|
-
|
|
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
|
+
});
|
|
4085
4204
|
await handlers.get("session_shutdown")?.({}, context);
|
|
4086
4205
|
// DR-1 keeps the unregistered row listed, so the two refusals stay distinct:
|
|
4087
4206
|
// a matching generation on a terminal row is terminally gone (no endpoint will
|
|
@@ -4105,6 +4224,185 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4105
4224
|
}
|
|
4106
4225
|
});
|
|
4107
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
|
+
|
|
4108
4406
|
test("rejects lifecycle-required SDK-only startup when broker registration fails", async () => {
|
|
4109
4407
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-broker-required-"));
|
|
4110
4408
|
const agentDir = path.join(cwd, ".gjc", "agent");
|
|
@@ -4116,7 +4414,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4116
4414
|
handlers.set(event, handler);
|
|
4117
4415
|
},
|
|
4118
4416
|
} as any;
|
|
4119
|
-
|
|
4417
|
+
createTestRuntimeExtension(api, {
|
|
4120
4418
|
agentDir,
|
|
4121
4419
|
brokerRegistrationRequired: true,
|
|
4122
4420
|
lifecycleRequestId: "broker-required-marker",
|
|
@@ -4139,10 +4437,112 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4139
4437
|
}
|
|
4140
4438
|
});
|
|
4141
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
|
+
|
|
4142
4539
|
interface PreflightHooks {
|
|
4540
|
+
onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
|
|
4143
4541
|
onPreflightAccepted?: () => void;
|
|
4144
4542
|
onPreflightAcceptCommit?: () => void | Promise<void>;
|
|
4145
4543
|
expectedSdkRunToken?: string;
|
|
4544
|
+
preflightSignal?: AbortSignal;
|
|
4545
|
+
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
4146
4546
|
}
|
|
4147
4547
|
|
|
4148
4548
|
interface ResponseFrame {
|
|
@@ -4150,11 +4550,22 @@ interface ResponseFrame {
|
|
|
4150
4550
|
ok?: boolean;
|
|
4151
4551
|
code?: string;
|
|
4152
4552
|
error?: { code: string; message: string };
|
|
4153
|
-
result?: {
|
|
4553
|
+
result?: {
|
|
4554
|
+
status?: string;
|
|
4555
|
+
commandId?: string;
|
|
4556
|
+
turnId?: string;
|
|
4557
|
+
error?: { code: string; message: string };
|
|
4558
|
+
};
|
|
4154
4559
|
}
|
|
4155
4560
|
|
|
4156
4561
|
interface InvocationHarness {
|
|
4157
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>;
|
|
4158
4569
|
query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
4159
4570
|
emit(event: string, payload?: unknown): Promise<void>;
|
|
4160
4571
|
switchSession(sessionId: string): Promise<void>;
|
|
@@ -4226,7 +4637,7 @@ async function invocationHarness(
|
|
|
4226
4637
|
hooks.onDurableAttempt,
|
|
4227
4638
|
)
|
|
4228
4639
|
: undefined;
|
|
4229
|
-
|
|
4640
|
+
createTestRuntimeExtension(api, {
|
|
4230
4641
|
agentDir: cwd,
|
|
4231
4642
|
...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
|
|
4232
4643
|
...(interceptorStore || hooks.terminalAbortSeams
|
|
@@ -4292,16 +4703,26 @@ async function invocationHarness(
|
|
|
4292
4703
|
},
|
|
4293
4704
|
};
|
|
4294
4705
|
await handlers.get("session_start")?.({}, ctx);
|
|
4295
|
-
const request = (frame: Record<string, unknown
|
|
4706
|
+
const request = (frame: Record<string, unknown>, connectionId = "client"): Promise<ResponseFrame> => {
|
|
4296
4707
|
const id = `frame-${nextId}`;
|
|
4297
4708
|
nextId += 1;
|
|
4298
4709
|
const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
|
|
4299
4710
|
waiters.set(id, resolve);
|
|
4300
|
-
deliver?.(
|
|
4711
|
+
deliver?.(connectionId, { ...frame, id } as SdkFrame);
|
|
4301
4712
|
return promise;
|
|
4302
4713
|
};
|
|
4303
4714
|
return {
|
|
4304
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
|
+
),
|
|
4305
4726
|
query: (name, input) => request({ type: "query_request", query: name, input }),
|
|
4306
4727
|
broadcasts,
|
|
4307
4728
|
emit: async (event, payload) => {
|
|
@@ -4351,93 +4772,587 @@ async function invocationHarness(
|
|
|
4351
4772
|
};
|
|
4352
4773
|
}
|
|
4353
4774
|
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
type: string | undefined;
|
|
4368
|
-
commandId?: string;
|
|
4369
|
-
turnId?: string;
|
|
4370
|
-
outcome: "rejected" | "committed";
|
|
4371
|
-
}) => void,
|
|
4372
|
-
): SdkOnlyReconciliationStore {
|
|
4373
|
-
const failureCounts = new Map<string, number>();
|
|
4374
|
-
let nextHold = 0;
|
|
4375
|
-
const backing = createInterceptorBackingStore();
|
|
4376
|
-
return {
|
|
4377
|
-
path: null,
|
|
4378
|
-
load: async () => backing.records.map(record => ({ ...record })),
|
|
4379
|
-
async transact(mutator) {
|
|
4380
|
-
const records = mutator(backing.records.map(record => ({ ...record })));
|
|
4381
|
-
let transitionType: string | undefined;
|
|
4382
|
-
for (const record of records as Array<{
|
|
4383
|
-
status?: string;
|
|
4384
|
-
terminalAt?: number;
|
|
4385
|
-
commandId?: string;
|
|
4386
|
-
pendingOutcome?: unknown;
|
|
4387
|
-
}>) {
|
|
4388
|
-
// The intermediate durable state an agent_failed write produces:
|
|
4389
|
-
// the reason is set but the row is not terminal yet. Tests choose how
|
|
4390
|
-
// many consecutive writes fail before the recovery replay succeeds.
|
|
4391
|
-
const identity = String(record?.commandId ?? "unknown");
|
|
4392
|
-
const failureCount = failureCounts.get(identity) ?? 0;
|
|
4393
|
-
if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
|
|
4394
|
-
transitionType ??= "agent_failed";
|
|
4395
|
-
if (
|
|
4396
|
-
record?.terminalAt === undefined &&
|
|
4397
|
-
(record as { error?: unknown }).error !== undefined &&
|
|
4398
|
-
failureCount < agentFailedWriteFailures
|
|
4399
|
-
) {
|
|
4400
|
-
transitionType = "agent_failed";
|
|
4401
|
-
failureCounts.set(identity, failureCount + 1);
|
|
4402
|
-
interceptor({ type: "agent_failed" });
|
|
4403
|
-
onDurableAttempt?.({
|
|
4404
|
-
type: "agent_failed",
|
|
4405
|
-
commandId: record?.commandId,
|
|
4406
|
-
turnId: (record as { turnId?: string }).turnId,
|
|
4407
|
-
outcome: "rejected",
|
|
4408
|
-
});
|
|
4409
|
-
throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
|
|
4410
|
-
}
|
|
4411
|
-
const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
|
|
4412
|
-
const previousPendingOutcome = (
|
|
4413
|
-
previous as
|
|
4414
|
-
| (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
|
|
4415
|
-
| undefined
|
|
4416
|
-
)?.pendingOutcome;
|
|
4417
|
-
const pendingOutcome = record.pendingOutcome as
|
|
4418
|
-
| { kind?: string; code?: string; reason?: string }
|
|
4419
|
-
| undefined;
|
|
4420
|
-
if (record.terminalAt === undefined && pendingOutcome !== undefined) {
|
|
4421
|
-
if (previousPendingOutcome === undefined) transitionType ??= "deadline_claim";
|
|
4422
|
-
else if (
|
|
4423
|
-
pendingOutcome.kind !== previousPendingOutcome.kind ||
|
|
4424
|
-
pendingOutcome.code !== previousPendingOutcome.code ||
|
|
4425
|
-
pendingOutcome.reason !== previousPendingOutcome.reason
|
|
4426
|
-
)
|
|
4427
|
-
transitionType = "pending_terminal_outcome";
|
|
4428
|
-
}
|
|
4429
|
-
if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
|
|
4430
|
-
transitionType = "agent_end";
|
|
4431
|
-
interceptor({ type: "agent_end" });
|
|
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;
|
|
4432
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
|
+
|
|
5269
|
+
/** Reconciliation store used to inject durable-write failures from tests: wraps a
|
|
5270
|
+
* session-file-backed store and fails the write whenever the staged records contain
|
|
5271
|
+
* the intermediate failed-without-terminal state an agent_failed transition persists. */
|
|
5272
|
+
function createInterceptorReconciliationStore(
|
|
5273
|
+
interceptor: (transition: { type: string }) => void,
|
|
5274
|
+
agentFailedWriteFailures = 1,
|
|
5275
|
+
persistHolds?: Array<{ type: string; onEntered: () => void; release: Promise<void> }>,
|
|
5276
|
+
// TEST-ONLY observability. `interceptor` fires only on REJECTED agent_failed
|
|
5277
|
+
// writes, so a successful recovery write is invisible to it. This observer sees
|
|
5278
|
+
// EVERY durable attempt with its correlation and whether it committed, so a
|
|
5279
|
+
// branch test can assert the third attempt actually happened instead of
|
|
5280
|
+
// inferring it from a missing callback.
|
|
5281
|
+
onDurableAttempt?: (attempt: {
|
|
5282
|
+
type: string | undefined;
|
|
5283
|
+
commandId?: string;
|
|
5284
|
+
turnId?: string;
|
|
5285
|
+
outcome: "rejected" | "committed";
|
|
5286
|
+
}) => void,
|
|
5287
|
+
): SdkOnlyReconciliationStore {
|
|
5288
|
+
const failureCounts = new Map<string, number>();
|
|
5289
|
+
let nextHold = 0;
|
|
5290
|
+
const backing = createInterceptorBackingStore();
|
|
5291
|
+
return {
|
|
5292
|
+
path: null,
|
|
5293
|
+
load: async () => backing.records.map(record => ({ ...record })),
|
|
5294
|
+
async transact(mutator) {
|
|
5295
|
+
const records = mutator(backing.records.map(record => ({ ...record })));
|
|
5296
|
+
let transitionType: string | undefined;
|
|
5297
|
+
for (const record of records as Array<{
|
|
5298
|
+
status?: string;
|
|
5299
|
+
terminalAt?: number;
|
|
5300
|
+
commandId?: string;
|
|
5301
|
+
pendingOutcome?: unknown;
|
|
5302
|
+
}>) {
|
|
5303
|
+
// The intermediate durable state an agent_failed write produces:
|
|
5304
|
+
// the reason is set but the row is not terminal yet. Tests choose how
|
|
5305
|
+
// many consecutive writes fail before the recovery replay succeeds.
|
|
5306
|
+
const identity = String(record?.commandId ?? "unknown");
|
|
5307
|
+
const failureCount = failureCounts.get(identity) ?? 0;
|
|
5308
|
+
if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
|
|
5309
|
+
transitionType ??= "agent_failed";
|
|
5310
|
+
if (
|
|
5311
|
+
record?.terminalAt === undefined &&
|
|
5312
|
+
(record as { error?: unknown }).error !== undefined &&
|
|
5313
|
+
failureCount < agentFailedWriteFailures
|
|
5314
|
+
) {
|
|
5315
|
+
transitionType = "agent_failed";
|
|
5316
|
+
failureCounts.set(identity, failureCount + 1);
|
|
5317
|
+
interceptor({ type: "agent_failed" });
|
|
5318
|
+
onDurableAttempt?.({
|
|
5319
|
+
type: "agent_failed",
|
|
5320
|
+
commandId: record?.commandId,
|
|
5321
|
+
turnId: (record as { turnId?: string }).turnId,
|
|
5322
|
+
outcome: "rejected",
|
|
5323
|
+
});
|
|
5324
|
+
throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
|
|
5325
|
+
}
|
|
5326
|
+
const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
|
|
5327
|
+
const previousPendingOutcome = (
|
|
5328
|
+
previous as
|
|
5329
|
+
| (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
|
|
5330
|
+
| undefined
|
|
5331
|
+
)?.pendingOutcome;
|
|
5332
|
+
const pendingOutcome = record.pendingOutcome as
|
|
5333
|
+
| { kind?: string; code?: string; reason?: string }
|
|
5334
|
+
| undefined;
|
|
5335
|
+
if (record.terminalAt === undefined && pendingOutcome !== undefined) {
|
|
5336
|
+
if (previousPendingOutcome === undefined) transitionType ??= "deadline_claim";
|
|
5337
|
+
else if (
|
|
5338
|
+
pendingOutcome.kind !== previousPendingOutcome.kind ||
|
|
5339
|
+
pendingOutcome.code !== previousPendingOutcome.code ||
|
|
5340
|
+
pendingOutcome.reason !== previousPendingOutcome.reason
|
|
5341
|
+
)
|
|
5342
|
+
transitionType = "pending_terminal_outcome";
|
|
5343
|
+
}
|
|
5344
|
+
if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
|
|
5345
|
+
transitionType = "agent_end";
|
|
5346
|
+
interceptor({ type: "agent_end" });
|
|
5347
|
+
}
|
|
5348
|
+
}
|
|
5349
|
+
if (transitionType === "pending_terminal_outcome") interceptor({ type: transitionType });
|
|
5350
|
+
const hold = persistHolds?.[nextHold];
|
|
5351
|
+
if (hold && hold.type === transitionType) {
|
|
5352
|
+
nextHold += 1;
|
|
5353
|
+
hold.onEntered();
|
|
5354
|
+
await hold.release;
|
|
4433
5355
|
}
|
|
4434
|
-
if (transitionType === "pending_terminal_outcome") interceptor({ type: transitionType });
|
|
4435
|
-
const hold = persistHolds?.[nextHold];
|
|
4436
|
-
if (hold && hold.type === transitionType) {
|
|
4437
|
-
nextHold += 1;
|
|
4438
|
-
hold.onEntered();
|
|
4439
|
-
await hold.release;
|
|
4440
|
-
}
|
|
4441
5356
|
for (const record of records as Array<{ commandId?: string; turnId?: string }>)
|
|
4442
5357
|
onDurableAttempt?.({
|
|
4443
5358
|
type: transitionType,
|
|
@@ -4479,48 +5394,981 @@ async function settledStatus(
|
|
|
4479
5394
|
}
|
|
4480
5395
|
}
|
|
4481
5396
|
|
|
4482
|
-
function neverSettlingPromise(): Promise<void> {
|
|
4483
|
-
return Promise.withResolvers<void>().promise;
|
|
4484
|
-
}
|
|
5397
|
+
function neverSettlingPromise(): Promise<void> {
|
|
5398
|
+
return Promise.withResolvers<void>().promise;
|
|
5399
|
+
}
|
|
5400
|
+
|
|
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, {
|
|
5815
|
+
sendUserMessage: async (content, options) => {
|
|
5816
|
+
calls.push({
|
|
5817
|
+
content,
|
|
5818
|
+
deliverAs: options?.deliverAs,
|
|
5819
|
+
expectedSdkRunToken: options?.expectedSdkRunToken,
|
|
5820
|
+
});
|
|
5821
|
+
if (options?.expectedSdkRunToken === staleToken)
|
|
5822
|
+
throw Object.assign(new Error("The expected SDK run is not active."), { code: "turn_not_active" });
|
|
5823
|
+
return "completed";
|
|
5824
|
+
},
|
|
5825
|
+
});
|
|
5826
|
+
|
|
5827
|
+
const rejected = await harness.control("turn.steer", {
|
|
5828
|
+
text: "stale steer",
|
|
5829
|
+
expectedSdkRunToken: staleToken,
|
|
5830
|
+
clientRef,
|
|
5831
|
+
});
|
|
5832
|
+
expect(rejected).toMatchObject({ ok: false, error: { code: "turn_not_active" } });
|
|
5833
|
+
expect(calls).toEqual([{ content: "stale steer", deliverAs: "steer", expectedSdkRunToken: staleToken }]);
|
|
5834
|
+
expect(await harness.query("turn.steer_status", { clientRef })).toMatchObject({
|
|
5835
|
+
ok: true,
|
|
5836
|
+
result: { status: "rejected" },
|
|
5837
|
+
});
|
|
5838
|
+
} finally {
|
|
5839
|
+
if (harness) await harness.stop();
|
|
5840
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5841
|
+
}
|
|
5842
|
+
});
|
|
5843
|
+
|
|
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
|
+
});
|
|
4485
6323
|
|
|
4486
|
-
test("
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
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 = {};
|
|
4505
6344
|
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
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
|
+
});
|
|
4522
6371
|
|
|
4523
|
-
describe("post-acceptance invocation terminalization", () => {
|
|
4524
6372
|
test.each([
|
|
4525
6373
|
{ status: 402, code: "provider_http_402" },
|
|
4526
6374
|
{ status: 429, code: "provider_http_429" },
|
|
@@ -5168,6 +7016,85 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5168
7016
|
}
|
|
5169
7017
|
});
|
|
5170
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
|
+
|
|
5171
7098
|
test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
|
|
5172
7099
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
|
|
5173
7100
|
try {
|
|
@@ -5233,6 +7160,129 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5233
7160
|
await rm(cwd, { recursive: true, force: true });
|
|
5234
7161
|
}
|
|
5235
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
|
+
|
|
5236
7286
|
test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
|
|
5237
7287
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
|
|
5238
7288
|
try {
|
|
@@ -5696,104 +7746,101 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5696
7746
|
await rm(cwd, { recursive: true, force: true });
|
|
5697
7747
|
}
|
|
5698
7748
|
});
|
|
5699
|
-
test(
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
const
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
content: testCase.content,
|
|
5771
|
-
...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
|
|
5772
|
-
},
|
|
5773
|
-
],
|
|
5774
|
-
});
|
|
5775
|
-
expect(
|
|
5776
|
-
await settledStatus(harness, "turn.result", {
|
|
5777
|
-
kind: "prompt",
|
|
5778
|
-
commandId: accepted.result?.commandId,
|
|
5779
|
-
turnId: accepted.result?.turnId,
|
|
5780
|
-
}),
|
|
5781
|
-
).toMatchObject({
|
|
5782
|
-
status: "failed",
|
|
5783
|
-
error: { code: "prompt_failed", message: "Agent run failed after execution started." },
|
|
5784
|
-
outcome: {
|
|
5785
|
-
kind: "failed",
|
|
5786
|
-
code: "prompt_failed",
|
|
5787
|
-
message: "Agent run failed after execution started.",
|
|
5788
|
-
provenance: "agent_failed",
|
|
5789
|
-
phase: "post_start",
|
|
5790
|
-
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 }),
|
|
5791
7820
|
},
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
await
|
|
5796
|
-
|
|
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 });
|
|
5797
7844
|
}
|
|
5798
7845
|
});
|
|
5799
7846
|
test("preserves explicit cancellation for an empty zero-token turn", async () => {
|
|
@@ -5857,114 +7904,111 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5857
7904
|
await rm(cwd, { recursive: true, force: true });
|
|
5858
7905
|
}
|
|
5859
7906
|
});
|
|
5860
|
-
test(
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
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
|
-
const
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
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 }],
|
|
5939
7995
|
});
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
harness.emit("agent_end", {
|
|
5949
|
-
messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
|
|
5950
|
-
});
|
|
5951
|
-
if (testCase.order === "completion-first") {
|
|
5952
|
-
completion.resolve(testCase.completion);
|
|
5953
|
-
await completionReconciled.promise;
|
|
5954
|
-
await emitAgentEnd();
|
|
5955
|
-
} else {
|
|
5956
|
-
await emitAgentEnd();
|
|
5957
|
-
completion.resolve(testCase.completion);
|
|
5958
|
-
await completionReconciled.promise;
|
|
5959
|
-
}
|
|
5960
|
-
const result = await harness.query("turn.result", selector);
|
|
5961
|
-
const content = (result.result as { content?: { text?: string } } | undefined)?.content;
|
|
5962
|
-
expect(content?.text).toBe(testCase.expected);
|
|
5963
|
-
await harness.stop();
|
|
5964
|
-
} finally {
|
|
5965
|
-
completion.resolve(undefined);
|
|
5966
|
-
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;
|
|
5967
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 });
|
|
5968
8012
|
}
|
|
5969
8013
|
}, 30_000);
|
|
5970
8014
|
test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
|
|
@@ -5975,6 +8019,11 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
5975
8019
|
sendUserMessage: async (_content, options) => {
|
|
5976
8020
|
if (options?.deliverAs === "followUp") {
|
|
5977
8021
|
await options?.onPreflightAcceptCommit?.();
|
|
8022
|
+
options.preflightSignal?.addEventListener(
|
|
8023
|
+
"abort",
|
|
8024
|
+
() => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
|
|
8025
|
+
{ once: true },
|
|
8026
|
+
);
|
|
5978
8027
|
// #queueFollowUp resolves immediately; the turn has not run yet.
|
|
5979
8028
|
return;
|
|
5980
8029
|
}
|
|
@@ -6037,6 +8086,11 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
6037
8086
|
sendUserMessage: async (_content, options) => {
|
|
6038
8087
|
// Session is streaming: sendUserMessage diverts to #queueSteer and resolves.
|
|
6039
8088
|
await options?.onPreflightAcceptCommit?.();
|
|
8089
|
+
options?.preflightSignal?.addEventListener(
|
|
8090
|
+
"abort",
|
|
8091
|
+
() => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
|
|
8092
|
+
{ once: true },
|
|
8093
|
+
);
|
|
6040
8094
|
},
|
|
6041
8095
|
isIdle: () => false,
|
|
6042
8096
|
});
|
|
@@ -6061,6 +8115,11 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
6061
8115
|
// The session is streaming when the submission starts (divert to steer).
|
|
6062
8116
|
// During accept()->persist(), the prior turn unwinds and isIdle flips to true.
|
|
6063
8117
|
await options?.onPreflightAcceptCommit?.();
|
|
8118
|
+
options?.preflightSignal?.addEventListener(
|
|
8119
|
+
"abort",
|
|
8120
|
+
() => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
|
|
8121
|
+
{ once: true },
|
|
8122
|
+
);
|
|
6064
8123
|
idle = true;
|
|
6065
8124
|
},
|
|
6066
8125
|
isIdle: () => idle,
|
|
@@ -6887,10 +8946,16 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
6887
8946
|
promptDeadlineMs = 100;
|
|
6888
8947
|
const queued = await harness.control("turn.follow_up", { text: "queued prompt" });
|
|
6889
8948
|
expect(queued.ok).toBe(true);
|
|
6890
|
-
promoted?.({ startsOwnRun: true });
|
|
6891
|
-
promptDeadlineMs = 600_000;
|
|
6892
8949
|
const activeIds = { commandId: active.result?.commandId, turnId: active.result?.turnId };
|
|
6893
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;
|
|
6894
8959
|
await Bun.sleep(150);
|
|
6895
8960
|
expect((await harness.query("turn.prompt_status", queuedIds)).result?.status).toMatch(/accepted|in_flight/);
|
|
6896
8961
|
expect(await harness.query("turn.prompt_status", activeIds)).toMatchObject({
|
|
@@ -7670,7 +9735,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
7670
9735
|
sessionId: transport.sessionId,
|
|
7671
9736
|
});
|
|
7672
9737
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
7673
|
-
|
|
9738
|
+
createTestRuntimeExtension(api, {
|
|
7674
9739
|
agentDir: cwd,
|
|
7675
9740
|
createTransport: async () => transport,
|
|
7676
9741
|
terminalAbortSeams: {
|
|
@@ -7733,6 +9798,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
7733
9798
|
expect((await statusOf(idsB, "inrun-status-b")).result?.status).toBe("terminal_ok");
|
|
7734
9799
|
// A separate later turn starts: the consumed correlation must NOT be
|
|
7735
9800
|
// drained into it, so conn-b owns nothing and its abort is refused.
|
|
9801
|
+
idle = true;
|
|
7736
9802
|
transport.feed("conn-c", {
|
|
7737
9803
|
type: "control_request",
|
|
7738
9804
|
id: "inrun-c",
|
|
@@ -8743,7 +10809,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8743
10809
|
sessionId: transport.sessionId,
|
|
8744
10810
|
});
|
|
8745
10811
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
8746
|
-
|
|
10812
|
+
createTestRuntimeExtension(api, {
|
|
8747
10813
|
agentDir: cwd,
|
|
8748
10814
|
createTransport: async () => transport,
|
|
8749
10815
|
terminalAbortSeams: {
|
|
@@ -8884,7 +10950,7 @@ test("SDK-only host never advances a finalized uncertain row for a mismatched re
|
|
|
8884
10950
|
sessionId: transport.sessionId,
|
|
8885
10951
|
});
|
|
8886
10952
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
8887
|
-
|
|
10953
|
+
createTestRuntimeExtension(api, {
|
|
8888
10954
|
agentDir: cwd,
|
|
8889
10955
|
createTransport: async () => transport,
|
|
8890
10956
|
terminalAbortSeams: {
|
|
@@ -8977,7 +11043,7 @@ test("SDK-only host FIFO-expires tombstones instead of failing the finalization
|
|
|
8977
11043
|
sessionFile: path.join(cwd, "session.json"),
|
|
8978
11044
|
sessionId: transport.sessionId,
|
|
8979
11045
|
});
|
|
8980
|
-
|
|
11046
|
+
createTestRuntimeExtension(api, {
|
|
8981
11047
|
agentDir: cwd,
|
|
8982
11048
|
createTransport: async () => transport,
|
|
8983
11049
|
terminalAbortSeams: {
|
|
@@ -9053,7 +11119,7 @@ test("SDK-only host cancels only the aborting requester's preflight while anothe
|
|
|
9053
11119
|
sessionFile: path.join(cwd, "session.json"),
|
|
9054
11120
|
sessionId: transport.sessionId,
|
|
9055
11121
|
});
|
|
9056
|
-
|
|
11122
|
+
createTestRuntimeExtension(api, {
|
|
9057
11123
|
agentDir: cwd,
|
|
9058
11124
|
createTransport: async () => transport,
|
|
9059
11125
|
terminalAbortSeams: {
|
|
@@ -9143,7 +11209,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
|
|
|
9143
11209
|
sessionFile: path.join(cwd, "session.json"),
|
|
9144
11210
|
sessionId: transport.sessionId,
|
|
9145
11211
|
});
|
|
9146
|
-
|
|
11212
|
+
createTestRuntimeExtension(api, {
|
|
9147
11213
|
agentDir: cwd,
|
|
9148
11214
|
createTransport: async () => transport,
|
|
9149
11215
|
terminalAbortSeams: {
|
|
@@ -9216,7 +11282,7 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
|
|
|
9216
11282
|
});
|
|
9217
11283
|
let captureCalls = 0;
|
|
9218
11284
|
let discardCalls = 0;
|
|
9219
|
-
|
|
11285
|
+
createTestRuntimeExtension(api, {
|
|
9220
11286
|
agentDir: cwd,
|
|
9221
11287
|
createTransport: async () => transport,
|
|
9222
11288
|
terminalAbortSeams: {
|
|
@@ -9331,6 +11397,318 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
|
|
|
9331
11397
|
}
|
|
9332
11398
|
});
|
|
9333
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
|
+
|
|
9334
11712
|
test("SDK-only host does not assign a follow-up requester ownership until the follow-up actually starts", async () => {
|
|
9335
11713
|
// Review thread P1: a turn.follow_up accepted while ctx.isIdle() is true
|
|
9336
11714
|
// but the follow-up is never promoted (compaction, transcript ending in a
|
|
@@ -9341,23 +11719,30 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
|
|
|
9341
11719
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-followup-stale-"));
|
|
9342
11720
|
const idle = true;
|
|
9343
11721
|
let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
|
|
11722
|
+
let queueSignal: AbortSignal | undefined;
|
|
9344
11723
|
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
9345
11724
|
const api = {
|
|
9346
11725
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
9347
11726
|
handlers.set(event, handler);
|
|
9348
11727
|
},
|
|
9349
|
-
sendUserMessage: (
|
|
9350
|
-
_content: string,
|
|
9351
|
-
options:
|
|
9352
|
-
| {
|
|
9353
|
-
onPreflightAccepted?: () => void;
|
|
9354
|
-
onPreflightAcceptCommit?: () => void;
|
|
9355
|
-
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
9356
|
-
}
|
|
9357
|
-
| undefined,
|
|
9358
|
-
) =>
|
|
11728
|
+
sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
|
|
9359
11729
|
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
9360
|
-
|
|
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
|
+
);
|
|
9361
11746
|
options?.onPreflightAccepted?.();
|
|
9362
11747
|
return {};
|
|
9363
11748
|
}),
|
|
@@ -9368,7 +11753,7 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
|
|
|
9368
11753
|
sessionId: transport.sessionId,
|
|
9369
11754
|
});
|
|
9370
11755
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
9371
|
-
|
|
11756
|
+
createTestRuntimeExtension(api, {
|
|
9372
11757
|
agentDir: cwd,
|
|
9373
11758
|
createTransport: async () => transport,
|
|
9374
11759
|
terminalAbortSeams: {
|
|
@@ -9424,7 +11809,16 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
|
|
|
9424
11809
|
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
9425
11810
|
});
|
|
9426
11811
|
expect(seamCalls).toHaveLength(0);
|
|
9427
|
-
|
|
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);
|
|
9428
11822
|
promoted!({ startsOwnRun: true });
|
|
9429
11823
|
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
9430
11824
|
transport.feed("conn-b", {
|
|
@@ -9481,7 +11875,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
|
|
|
9481
11875
|
sessionId: transport.sessionId,
|
|
9482
11876
|
});
|
|
9483
11877
|
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
9484
|
-
|
|
11878
|
+
createTestRuntimeExtension(api, {
|
|
9485
11879
|
agentDir: cwd,
|
|
9486
11880
|
createTransport: async () => transport,
|
|
9487
11881
|
terminalAbortSeams: {
|
|
@@ -9606,7 +12000,7 @@ test("SDK-only host rebinds the steering snapshot when the requester's turn wins
|
|
|
9606
12000
|
let ownerReads = 0;
|
|
9607
12001
|
let rebindCalls = 0;
|
|
9608
12002
|
const settledOptions: Array<{ scope?: string; steeringSnapshotToken?: number }> = [];
|
|
9609
|
-
|
|
12003
|
+
createTestRuntimeExtension(api, {
|
|
9610
12004
|
agentDir: cwd,
|
|
9611
12005
|
createTransport: async () => transport,
|
|
9612
12006
|
terminalAbortSeams: {
|