@gajae-code/coding-agent 0.13.2 → 0.14.0
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 +215 -8
- package/README.md +2 -0
- package/bin/gajaessi.js +4 -0
- package/dist/types/async/job-manager.d.ts +55 -2
- package/dist/types/async/support.d.ts +14 -0
- package/dist/types/capability/mcp.d.ts +6 -0
- package/dist/types/capability/types.d.ts +6 -0
- package/dist/types/cli/accounts-cli.d.ts +19 -0
- package/dist/types/cli/args.d.ts +3 -0
- package/dist/types/cli/config-cli.d.ts +6 -0
- package/dist/types/cli/crash-cli.d.ts +10 -0
- package/dist/types/cli/customize-doctor.d.ts +145 -0
- package/dist/types/cli/daemon-cli.d.ts +3 -1
- package/dist/types/cli/mcp-cli.d.ts +11 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/root-flags.d.ts +12 -0
- package/dist/types/cli/setup-cli.d.ts +3 -1
- package/dist/types/cli/skills-cli.d.ts +2 -1
- package/dist/types/cli/update-cli.d.ts +19 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/accounts.d.ts +40 -0
- package/dist/types/commands/crash.d.ts +24 -0
- package/dist/types/commands/customize.d.ts +22 -0
- package/dist/types/commands/daemon.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/commands/skills.d.ts +6 -1
- package/dist/types/commands/update.d.ts +7 -0
- package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
- package/dist/types/config/config-schema-version.d.ts +10 -0
- package/dist/types/config/file-lock.d.ts +18 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +10 -1
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +73 -9
- package/dist/types/config/settings.d.ts +63 -0
- package/dist/types/config/skill-settings-defaults.d.ts +26 -0
- package/dist/types/config/task-delegation.d.ts +39 -0
- package/dist/types/config/update-channel.d.ts +10 -0
- package/dist/types/coordinator-mcp/server.d.ts +5 -1
- package/dist/types/crash/index-store.d.ts +87 -0
- package/dist/types/crash/nudge.d.ts +50 -0
- package/dist/types/crash/record-loader.d.ts +17 -0
- package/dist/types/crash/report.d.ts +134 -0
- package/dist/types/crash/sanitize.d.ts +25 -0
- package/dist/types/cursor.d.ts +13 -2
- package/dist/types/customization/import.d.ts +37 -0
- package/dist/types/customization/index.d.ts +4 -0
- package/dist/types/customization/inventory.d.ts +25 -0
- package/dist/types/customization/mutations.d.ts +32 -0
- package/dist/types/customization/types.d.ts +115 -0
- package/dist/types/daemon/control-types.d.ts +8 -1
- package/dist/types/daemon/operator-contract.d.ts +4 -4
- package/dist/types/discovery/claude.d.ts +39 -1
- package/dist/types/discovery/codex.d.ts +39 -1
- package/dist/types/discovery/index.d.ts +2 -0
- package/dist/types/discovery/mcp-compat.d.ts +30 -0
- package/dist/types/edit/renderer.d.ts +23 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -4
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
- package/dist/types/extensibility/hooks/loader.d.ts +19 -5
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
- package/dist/types/extensibility/skill-management.d.ts +107 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
- package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
- package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
- package/dist/types/hooks/events.d.ts +60 -0
- package/dist/types/hooks/normalize.d.ts +83 -0
- package/dist/types/hooks/skill-state.d.ts +9 -2
- package/dist/types/main.d.ts +2 -1
- package/dist/types/modes/acp/abort-scope.d.ts +13 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -4
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
- package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
- package/dist/types/modes/components/customization/index.d.ts +1 -0
- package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
- package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
- package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
- package/dist/types/modes/components/oauth-selector.d.ts +10 -4
- package/dist/types/modes/components/pet-capability.d.ts +7 -21
- package/dist/types/modes/components/settings-selector.d.ts +5 -0
- package/dist/types/modes/components/status-line/gh.d.ts +6 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
- package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/theme/defaults/index.d.ts +118 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
- package/dist/types/runtime-mcp/config.d.ts +9 -0
- package/dist/types/runtime-mcp/manager.d.ts +26 -1
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
- package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
- package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
- package/dist/types/runtime-mcp/protocol.d.ts +278 -0
- package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
- package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
- package/dist/types/runtime-mcp/types.d.ts +102 -10
- package/dist/types/sdk/acp/adapter.d.ts +26 -5
- package/dist/types/sdk/broker/broker.d.ts +3 -55
- package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
- package/dist/types/sdk/broker/session-index.d.ts +14 -5
- package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
- package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
- package/dist/types/sdk/bus/index.d.ts +85 -99
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
- package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
- package/dist/types/sdk/bus/notification-service.d.ts +52 -0
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
- package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
- package/dist/types/sdk/bus/session-control.d.ts +25 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
- package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
- package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
- package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
- package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
- package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
- package/dist/types/sdk/cli/rows.d.ts +2 -11
- package/dist/types/sdk/cli/session-cli.d.ts +15 -25
- package/dist/types/sdk/client/client.d.ts +97 -1
- package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +18 -5
- package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
- package/dist/types/sdk/host/host.d.ts +15 -1
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +127 -7
- package/dist/types/sdk/host/types.d.ts +1 -1
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +2 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
- package/dist/types/sdk/lifecycle/client.d.ts +59 -0
- package/dist/types/sdk/lifecycle/index.d.ts +3 -0
- package/dist/types/sdk/lifecycle/service.d.ts +232 -0
- package/dist/types/sdk/mcp/server.d.ts +9 -6
- package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
- package/dist/types/sdk/prompt-status.d.ts +10 -3
- package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
- package/dist/types/sdk/receipt-state.d.ts +17 -0
- package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
- package/dist/types/sdk/router/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +152 -0
- package/dist/types/sdk/session-list.d.ts +22 -0
- package/dist/types/sdk/session-reconnect.d.ts +43 -1
- package/dist/types/sdk/session.d.ts +34 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
- package/dist/types/session/account-inventory.d.ts +86 -0
- package/dist/types/session/agent-session.d.ts +145 -6
- package/dist/types/session/agent-storage.d.ts +10 -0
- package/dist/types/session/coordinator-tool-label.d.ts +28 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/secure-token-file.d.ts +3 -0
- package/dist/types/session/session-manager.d.ts +47 -1
- package/dist/types/session/startup-auth-config.d.ts +44 -0
- package/dist/types/session/terminal-abort.d.ts +378 -0
- package/dist/types/session/yield-queue.d.ts +7 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +88 -0
- package/dist/types/session-import/command.d.ts +35 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/provider-codex.d.ts +22 -0
- package/dist/types/session-import/provider-service.d.ts +38 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +47 -0
- package/dist/types/session-import/types.d.ts +120 -0
- package/dist/types/setup/paseo/check.d.ts +16 -0
- package/dist/types/setup/paseo/install-saga.d.ts +115 -0
- package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
- package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
- package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
- package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
- package/dist/types/setup/paseo/provider-config.d.ts +35 -0
- package/dist/types/setup/paseo/remove.d.ts +14 -0
- package/dist/types/setup/paseo/result-types.d.ts +59 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
- package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
- package/dist/types/skill-state/active-state.d.ts +5 -1
- package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
- package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
- package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
- package/dist/types/slash-commands/types.d.ts +7 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/tools/ask-answer-registry.d.ts +6 -3
- package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
- package/dist/types/tools/bash.d.ts +2 -6
- package/dist/types/tools/browser/attach.d.ts +11 -0
- package/dist/types/tools/browser/launch.d.ts +20 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
- package/dist/types/tools/browser/render.d.ts +1 -0
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +10 -2
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/monitor.d.ts +1 -1
- package/dist/types/tools/skill-discovery.d.ts +8 -1
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
- package/dist/types/utils/herdr-pane.d.ts +76 -0
- package/package.json +19 -9
- package/scripts/build-binary.ts +2 -1
- package/scripts/build-sdk-package-smoke.ts +2 -13
- package/scripts/capture-sticky-viewport-showcase.ts +0 -1
- package/scripts/compile-args.ts +2 -0
- package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
- package/scripts/generate-sdk-operation-inventory.ts +28 -0
- package/scripts/generate-telegram-baseline-manifest.ts +1 -0
- package/scripts/generate-tool-catalog.ts +6 -4
- package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
- package/src/async/job-manager.ts +352 -17
- package/src/async/support.ts +25 -0
- package/src/autoresearch/dashboard.ts +28 -13
- package/src/capability/index.ts +3 -1
- package/src/capability/mcp.ts +6 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/accounts-cli.ts +395 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/auth-broker-cli.ts +136 -66
- package/src/cli/auth-gateway-cli.ts +94 -70
- package/src/cli/config-cli.ts +27 -2
- package/src/cli/crash-cli.ts +78 -0
- package/src/cli/customize-doctor.ts +1605 -0
- package/src/cli/daemon-cli.ts +12 -3
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/mcp-cli.ts +128 -60
- package/src/cli/notify-cli.ts +8 -14
- package/src/cli/root-flags.ts +8 -0
- package/src/cli/setup-cli.ts +145 -0
- package/src/cli/skills-cli.ts +38 -2
- package/src/cli/update-cli.ts +167 -12
- package/src/cli.ts +4 -0
- package/src/commands/accounts.ts +71 -0
- package/src/commands/crash.ts +35 -0
- package/src/commands/customize.ts +84 -0
- package/src/commands/daemon.ts +2 -2
- package/src/commands/deep-interview.ts +2 -0
- package/src/commands/launch.ts +1 -0
- package/src/commands/mcp.ts +12 -7
- package/src/commands/ralplan.ts +2 -0
- package/src/commands/sdk.ts +65 -32
- package/src/commands/setup.ts +5 -0
- package/src/commands/skills.ts +14 -4
- package/src/commands/ultragoal.ts +10 -1
- package/src/commands/update.ts +26 -15
- package/src/config/atomic-yaml-patch-worker.ts +57 -0
- package/src/config/atomic-yaml-patch.ts +769 -29
- package/src/config/config-schema-version.ts +10 -0
- package/src/config/file-lock.ts +70 -1
- package/src/config/mcp-schema.json +52 -14
- package/src/config/model-profile-activation.ts +23 -2
- package/src/config/model-profiles.ts +88 -21
- package/src/config/model-registry.ts +147 -21
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +77 -7
- package/src/config/settings.ts +4038 -199
- package/src/config/skill-settings-defaults.ts +41 -3
- package/src/config/task-delegation.ts +87 -0
- package/src/config/update-channel.ts +23 -0
- package/src/coordinator-mcp/server.ts +377 -308
- package/src/crash/index-store.ts +723 -0
- package/src/crash/nudge.ts +89 -0
- package/src/crash/record-loader.ts +149 -0
- package/src/crash/report.ts +551 -0
- package/src/crash/sanitize.ts +129 -0
- package/src/cursor.ts +36 -10
- package/src/customization/import.ts +946 -0
- package/src/customization/index.ts +4 -0
- package/src/customization/inventory.ts +335 -0
- package/src/customization/mutations.ts +141 -0
- package/src/customization/types.ts +223 -0
- package/src/daemon/control-types.ts +8 -1
- package/src/daemon/operator-contract.ts +6 -6
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
- package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/discovery/claude-hooks.ts +38 -0
- package/src/discovery/claude.ts +142 -209
- package/src/discovery/codex-hooks.ts +38 -0
- package/src/discovery/codex.ts +132 -198
- package/src/discovery/helpers.ts +8 -1
- package/src/discovery/index.ts +2 -0
- package/src/discovery/mcp-compat.ts +234 -0
- package/src/discovery/mcp-json.ts +6 -0
- package/src/edit/renderer.ts +40 -0
- package/src/edit/streaming.ts +4 -2
- package/src/exec/bash-executor.ts +24 -20
- package/src/export/html/index.ts +51 -2
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +17 -4
- package/src/extensibility/extensions/types.ts +31 -6
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
- package/src/extensibility/gjc-plugins/installer.ts +9 -5
- package/src/extensibility/gjc-plugins/observability.ts +8 -2
- package/src/extensibility/gjc-plugins/registry.ts +13 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
- package/src/extensibility/gjc-plugins/schema.ts +222 -39
- package/src/extensibility/hooks/loader.ts +154 -10
- package/src/extensibility/runtime-skill-discovery.ts +257 -60
- package/src/extensibility/skill-management.ts +368 -0
- package/src/extensibility/skills.ts +52 -16
- package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
- package/src/gjc-runtime/launch-worktree.ts +56 -2
- package/src/gjc-runtime/linux-proc.ts +7 -1
- package/src/gjc-runtime/ralplan-runtime.ts +100 -164
- package/src/gjc-runtime/session-state-lock.ts +992 -0
- package/src/gjc-runtime/session-state-sidecar.ts +644 -142
- package/src/gjc-runtime/state-writer.ts +39 -14
- package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
- package/src/gjc-runtime/ultragoal-guard.ts +11 -4
- package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
- package/src/gjc-runtime/workflow-settings.ts +596 -0
- package/src/goals/tools/goal-tool.ts +6 -1
- package/src/hooks/events.ts +291 -0
- package/src/hooks/native-skill-hook.ts +4 -0
- package/src/hooks/normalize.ts +369 -0
- package/src/hooks/skill-state.ts +165 -46
- package/src/internal-urls/docs-index.generated.ts +41 -32
- package/src/main.ts +36 -5
- package/src/migrate/mcp-mapper.ts +25 -2
- package/src/modes/acp/abort-scope.ts +29 -0
- package/src/modes/acp/acp-agent.ts +423 -98
- package/src/modes/components/assistant-message.ts +6 -1
- package/src/modes/components/chrome.ts +4 -3
- package/src/modes/components/customization/customization-dashboard.ts +443 -0
- package/src/modes/components/customization/import-wizard.ts +372 -0
- package/src/modes/components/customization/index.ts +1 -0
- package/src/modes/components/dynamic-theme-text.ts +22 -0
- package/src/modes/components/gajae-pet-widget.ts +607 -193
- package/src/modes/components/irc-sidebar.ts +11 -0
- package/src/modes/components/iterm-pet-transport.ts +585 -0
- package/src/modes/components/model-selector.ts +1 -6
- package/src/modes/components/oauth-selector.ts +277 -86
- package/src/modes/components/pet-capability.ts +35 -31
- package/src/modes/components/settings-selector.ts +29 -5
- package/src/modes/components/status-line/gh.ts +45 -1
- package/src/modes/components/status-line/segments.ts +19 -4
- package/src/modes/components/tool-status-header.ts +13 -3
- package/src/modes/controllers/command-controller.ts +20 -8
- package/src/modes/controllers/extension-ui-controller.ts +56 -257
- package/src/modes/controllers/input-controller.ts +10 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
- package/src/modes/controllers/selector-controller.ts +238 -77
- package/src/modes/controllers/stt-controller.ts +3 -3
- package/src/modes/interactive-mode.ts +88 -16
- package/src/modes/print-mode.ts +35 -6
- package/src/modes/runtime-init.ts +21 -68
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/theme/defaults/ouroboros.json +118 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/injected-user-submission.ts +25 -0
- package/src/modes/utils/terminal-bell.ts +15 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/critic.md +1 -1
- package/src/prompts/system/rlm-research.md +1 -1
- package/src/prompts/system/system-prompt.md +0 -2
- package/src/prompts/tools/bash.md +7 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +6 -6
- package/src/prompts/tools/read.md +0 -2
- package/src/prompts/tools/skill-discovery.md +4 -2
- package/src/runtime-mcp/client.ts +427 -23
- package/src/runtime-mcp/config.ts +37 -5
- package/src/runtime-mcp/manager-pool.test.ts +56 -0
- package/src/runtime-mcp/manager.ts +52 -2
- package/src/runtime-mcp/oauth-discovery.ts +13 -0
- package/src/runtime-mcp/oauth-flow.ts +46 -1
- package/src/runtime-mcp/pool-key.ts +5 -1
- package/src/runtime-mcp/pool.test.ts +9 -0
- package/src/runtime-mcp/protocol.test.ts +353 -0
- package/src/runtime-mcp/protocol.ts +624 -0
- package/src/runtime-mcp/scope-config.ts +78 -0
- package/src/runtime-mcp/tool-bridge.ts +44 -10
- package/src/runtime-mcp/transports/http.ts +84 -13
- package/src/runtime-mcp/types.ts +123 -12
- package/src/sdk/acp/adapter.ts +328 -97
- package/src/sdk/broker/broker.ts +160 -741
- package/src/sdk/broker/ensure.ts +9 -1
- package/src/sdk/broker/lifecycle.ts +510 -116
- package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
- package/src/sdk/broker/process-incarnation.ts +19 -0
- package/src/sdk/broker/session-index.ts +516 -117
- package/src/sdk/broker/startup-failure.ts +28 -5
- package/src/sdk/broker/transport.ts +1 -13
- package/src/sdk/bus/chat-daemon-cli.ts +4 -2
- package/src/sdk/bus/chat-daemon-control.ts +67 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
- package/src/sdk/bus/config.ts +13 -0
- package/src/sdk/bus/conversation-store.ts +59 -171
- package/src/sdk/bus/daemon-paths.ts +20 -0
- package/src/sdk/bus/discord-conversation.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +524 -126
- package/src/sdk/bus/discord-live-provider.ts +206 -79
- package/src/sdk/bus/existing-thread-readiness.ts +4 -97
- package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
- package/src/sdk/bus/index.ts +2480 -456
- package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
- package/src/sdk/bus/lifecycle-commands.ts +2 -2
- package/src/sdk/bus/managed-daemon.ts +2 -145
- package/src/sdk/bus/notification-service.ts +196 -0
- package/src/sdk/bus/operator-runtime.ts +1 -1
- package/src/sdk/bus/prompt-reconciliation.ts +24 -15
- package/src/sdk/bus/reconciliation-store.ts +553 -24
- package/src/sdk/bus/session-control.ts +49 -3
- package/src/sdk/bus/slack-conversation.ts +4 -0
- package/src/sdk/bus/slack-daemon.ts +733 -134
- package/src/sdk/bus/slack-live-provider.ts +65 -15
- package/src/sdk/bus/slack-provider.ts +4 -0
- package/src/sdk/bus/slack-thread-binding.ts +9 -104
- package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
- package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
- package/src/sdk/bus/telegram-daemon-control.ts +3 -13
- package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
- package/src/sdk/bus/telegram-daemon.ts +2552 -2280
- package/src/sdk/bus/telegram-reference.ts +1 -226
- package/src/sdk/bus/telegram-setup.ts +2 -2
- package/src/sdk/bus/threaded-render.ts +1 -0
- package/src/sdk/bus/topic-registry.ts +279 -146
- package/src/sdk/cli/rows.ts +2 -35
- package/src/sdk/cli/session-cli.ts +776 -700
- package/src/sdk/client/client.ts +892 -1
- package/src/sdk/host/control/dispatch.ts +93 -58
- package/src/sdk/host/control/index.ts +1 -1
- package/src/sdk/host/control/operations.ts +18 -10
- package/src/sdk/host/control/runtime-gate.ts +40 -0
- package/src/sdk/host/host.ts +63 -12
- package/src/sdk/host/query/handlers.ts +66 -7
- package/src/sdk/host/reverse-leases.ts +2 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +2787 -34
- package/src/sdk/host/session-runtime.ts +1489 -101
- package/src/sdk/host/surface-policy.ts +12 -8
- package/src/sdk/host/types.ts +1 -1
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
- package/src/sdk/host/websocket-transport.ts +85 -50
- package/src/sdk/index.ts +2 -1
- package/src/sdk/lifecycle/broker-client.ts +53 -0
- package/src/sdk/lifecycle/client.ts +234 -0
- package/src/sdk/lifecycle/index.ts +3 -0
- package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
- package/src/sdk/lifecycle/service.ts +720 -0
- package/src/sdk/mcp/server.ts +226 -188
- package/src/sdk/prompt-deadline-lease.ts +55 -0
- package/src/sdk/prompt-deadline-manager.ts +148 -0
- package/src/sdk/prompt-status.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +272 -129
- package/src/sdk/protocol/operation-registry.ts +71 -8
- package/src/sdk/receipt-state.ts +43 -0
- package/src/sdk/reconciliation-extensions.ts +14 -0
- package/src/sdk/router/index.ts +1 -0
- package/src/sdk/router/session-router.ts +1658 -0
- package/src/sdk/session-list.ts +93 -0
- package/src/sdk/session-reconnect.ts +45 -1
- package/src/sdk/session.ts +716 -120
- package/src/sdk/transport/serve-cli.ts +18 -34
- package/src/session/account-inventory.ts +603 -0
- package/src/session/agent-session.ts +2873 -559
- package/src/session/agent-storage.ts +35 -0
- package/src/session/blob-store.ts +32 -1
- package/src/session/coordinator-tool-label.ts +40 -0
- package/src/session/internal/managed-session-scope.ts +4 -0
- package/src/session/internal/managed-session-storage.ts +199 -31
- package/src/session/messages.ts +1 -1
- package/src/session/secure-token-file.ts +80 -0
- package/src/session/session-manager.ts +757 -57
- package/src/session/session-storage.ts +25 -16
- package/src/session/startup-auth-config.ts +297 -0
- package/src/session/terminal-abort.ts +1253 -0
- package/src/session/yield-queue.ts +57 -15
- package/src/session-import/claude.ts +334 -0
- package/src/session-import/codex.ts +1033 -0
- package/src/session-import/command.ts +77 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +7 -0
- package/src/session-import/provider-codex.ts +327 -0
- package/src/session-import/provider-service.ts +457 -0
- package/src/session-import/redact.ts +128 -0
- package/src/session-import/service.ts +1264 -0
- package/src/session-import/types.ts +148 -0
- package/src/setup/paseo/check.ts +213 -0
- package/src/setup/paseo/install-saga.ts +293 -0
- package/src/setup/paseo/json-publisher.ts +263 -0
- package/src/setup/paseo/orchestration-preferences.ts +86 -0
- package/src/setup/paseo/paseo-ownership.ts +224 -0
- package/src/setup/paseo/paseo-setup.ts +253 -0
- package/src/setup/paseo/provider-config.ts +139 -0
- package/src/setup/paseo/remove.ts +183 -0
- package/src/setup/paseo/result-types.ts +79 -0
- package/src/setup/paseo/setup-deps.ts +175 -0
- package/src/setup/paseo/skills-bridge.ts +260 -0
- package/src/skill-state/active-state.ts +21 -11
- package/src/slash-commands/acp-builtins.ts +9 -1
- package/src/slash-commands/builtin-registry.ts +117 -19
- package/src/slash-commands/helpers/credential-switch.ts +143 -0
- package/src/slash-commands/helpers/mcp.ts +9 -3
- package/src/slash-commands/helpers/usage-report.ts +61 -56
- package/src/slash-commands/types.ts +7 -2
- package/src/task/executor.ts +30 -11
- package/src/task/index.ts +66 -7
- package/src/task/render.ts +23 -9
- package/src/tools/ask-answer-registry.ts +23 -7
- package/src/tools/ask.ts +8 -4
- package/src/tools/bash-sleep-advisory.ts +115 -0
- package/src/tools/bash.ts +98 -18
- package/src/tools/browser/attach.ts +71 -3
- package/src/tools/browser/launch.ts +86 -13
- package/src/tools/browser/profile-discovery.ts +57 -7
- package/src/tools/browser/render.ts +8 -1
- package/src/tools/browser.ts +130 -21
- package/src/tools/computer.enforcement.test.ts +1 -1
- package/src/tools/computer.ts +3 -2
- package/src/tools/cron.ts +12 -1
- package/src/tools/image-gen.ts +349 -142
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +33 -6
- package/src/tools/monitor.ts +41 -2
- package/src/tools/skill-discovery.ts +21 -29
- package/src/tools/subagent.ts +142 -24
- package/src/tools/todo-contract.ts +55 -3
- package/src/tools/todo-write.ts +4 -3
- package/src/tools/tool-catalog.generated.ts +6 -6
- package/src/tools/ultragoal-ask-guard.ts +10 -3
- package/src/utils/herdr-pane.ts +345 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/scrapers/index.ts +6 -4
- package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
- package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
- package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
- package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
- package/dist/types/runtime/lazy-service.test.d.ts +0 -1
- package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
- package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
- package/dist/types/runtime-mcp/config.test.d.ts +0 -1
- package/dist/types/runtime-mcp/http.test.d.ts +0 -1
- package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
- package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
- package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
- package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
- package/dist/types/sdk/elevation/audit.d.ts +0 -93
- package/dist/types/sdk/elevation/capability.d.ts +0 -3
- package/dist/types/sdk/elevation/digest.d.ts +0 -72
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
- package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
- package/dist/types/sdk/elevation/owner.d.ts +0 -48
- package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
- package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
- package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
- package/dist/types/session/auth-broker-config.d.ts +0 -13
- package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
- package/dist/types/skills/skills.test.d.ts +0 -1
- package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
- package/dist/types/task/token-log.test.d.ts +0 -1
- package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
- package/dist/types/tools/descriptors.test.d.ts +0 -1
- package/scripts/g005-daemon-smoke.ts +0 -180
- package/scripts/g011-daemon-path-smoke.ts +0 -153
- package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
- package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
- package/src/sdk/bus/telegram-cli.ts +0 -212
- package/src/sdk/elevation/allowlist.ts +0 -54
- package/src/sdk/elevation/audit.ts +0 -208
- package/src/sdk/elevation/capability.ts +0 -41
- package/src/sdk/elevation/digest.ts +0 -129
- package/src/sdk/elevation/dispatch-receipt.ts +0 -184
- package/src/sdk/elevation/grant-ledger.ts +0 -1142
- package/src/sdk/elevation/owner.ts +0 -109
- package/src/session/auth-broker-config.ts +0 -109
- /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
- /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
- /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
5
|
-
import
|
|
6
|
+
import { AsyncJobManager } from "../../async";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "../../extensibility/extensions";
|
|
8
|
+
import {
|
|
9
|
+
registerOwnedRegistration,
|
|
10
|
+
resetTerminalAbortRegistriesForTests,
|
|
11
|
+
type TurnRegistrationKey,
|
|
12
|
+
unregisterOwnedRegistration,
|
|
13
|
+
} from "../../session/terminal-abort";
|
|
6
14
|
import { Broker } from "../broker/broker";
|
|
15
|
+
import { createReconciliationStore } from "../bus/reconciliation-store";
|
|
7
16
|
import {
|
|
8
17
|
createInvocationReconciliation,
|
|
9
18
|
createSdkSessionRuntimeExtension,
|
|
@@ -55,16 +64,29 @@ function memoryTransport(): SessionSdkTransport & {
|
|
|
55
64
|
};
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
function
|
|
67
|
+
function admissionBarrier(target: number) {
|
|
68
|
+
const ready = Promise.withResolvers<void>();
|
|
69
|
+
let admitted = 0;
|
|
70
|
+
return {
|
|
71
|
+
onFrameAdmitted() {
|
|
72
|
+
admitted += 1;
|
|
73
|
+
if (admitted === target) ready.resolve();
|
|
74
|
+
},
|
|
75
|
+
ready: ready.promise,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extensionContext(sessionId: string, cwd: string): ExtensionContext {
|
|
59
80
|
return {
|
|
60
81
|
cwd,
|
|
61
82
|
workflowGate: undefined,
|
|
62
83
|
sdkBindings: () => [],
|
|
63
84
|
sessionManager: {
|
|
64
85
|
getSessionId: () => sessionId,
|
|
86
|
+
getSessionFile: () => path.join(cwd, `${sessionId}.json`),
|
|
65
87
|
getSessionName: () => undefined,
|
|
66
88
|
},
|
|
67
|
-
};
|
|
89
|
+
} as unknown as ExtensionContext;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
test("preserves an agent failure code in host prompt reconciliation", async () => {
|
|
@@ -171,6 +193,62 @@ test("redacts a persisted host failure during hydration", async () => {
|
|
|
171
193
|
await rm(stateRoot, { recursive: true, force: true });
|
|
172
194
|
}
|
|
173
195
|
});
|
|
196
|
+
test("retains terminal host reconciliation records past the 15-minute window", async () => {
|
|
197
|
+
// #4547: terminal records must stay canonical until per-kind capacity
|
|
198
|
+
// eviction; age must never expire them on the SDK-only host path.
|
|
199
|
+
const realNow = Date.now;
|
|
200
|
+
let clock = 1_000_000;
|
|
201
|
+
Date.now = () => clock;
|
|
202
|
+
try {
|
|
203
|
+
const reconciliation = createInvocationReconciliation();
|
|
204
|
+
const promptCorrelation = { commandId: "aged-command", turnId: "aged-turn" };
|
|
205
|
+
const skillCorrelation = { commandId: "aged-skill-command", turnId: "aged-skill-turn" };
|
|
206
|
+
reconciliation.admit("prompt", "aged-ref");
|
|
207
|
+
reconciliation.admit("skill", "aged-skill-ref");
|
|
208
|
+
await reconciliation.noteAccepted("prompt", promptCorrelation, "aged-ref");
|
|
209
|
+
await reconciliation.noteAccepted("skill", skillCorrelation, "aged-skill-ref");
|
|
210
|
+
await reconciliation.noteTransition("prompt", promptCorrelation, { type: "agent_end" });
|
|
211
|
+
await reconciliation.noteTransition("skill", skillCorrelation, { type: "agent_end" });
|
|
212
|
+
|
|
213
|
+
clock += 15 * 60_000 + 1;
|
|
214
|
+
expect(reconciliation.lookup("prompt", { clientRef: "aged-ref" })).toMatchObject({
|
|
215
|
+
status: "terminal_ok",
|
|
216
|
+
});
|
|
217
|
+
expect(reconciliation.lookup("skill", { clientRef: "aged-skill-ref" })).toMatchObject({
|
|
218
|
+
status: "terminal_ok",
|
|
219
|
+
});
|
|
220
|
+
expect(() => reconciliation.admit("prompt", "aged-ref")).toThrowError(/never reuse a clientRef/);
|
|
221
|
+
} finally {
|
|
222
|
+
Date.now = realNow;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("capacity eviction still releases the clientRef and reports honest unknown", async () => {
|
|
227
|
+
// The removal of age eviction must not weaken the oldest-terminal-first
|
|
228
|
+
// capacity trim: at the cap the oldest terminal is evicted and its
|
|
229
|
+
// clientRef becomes admissible again with the prior outcome unknown.
|
|
230
|
+
const reconciliation = createInvocationReconciliation();
|
|
231
|
+
const first = { commandId: "capacity-command-1", turnId: "capacity-turn-1" };
|
|
232
|
+
reconciliation.admit("prompt", "capacity-ref-1");
|
|
233
|
+
await reconciliation.noteAccepted("prompt", first, "capacity-ref-1");
|
|
234
|
+
await reconciliation.noteTransition("prompt", first, { type: "agent_end" });
|
|
235
|
+
const TERMINAL_CAPACITY = 512;
|
|
236
|
+
for (let index = 2; index <= TERMINAL_CAPACITY + 1; index++) {
|
|
237
|
+
const correlation = { commandId: `capacity-command-${index}`, turnId: `capacity-turn-${index}` };
|
|
238
|
+
reconciliation.admit("prompt", `capacity-ref-${index}`);
|
|
239
|
+
await reconciliation.noteAccepted("prompt", correlation, `capacity-ref-${index}`);
|
|
240
|
+
await reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
|
|
241
|
+
}
|
|
242
|
+
expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
|
|
243
|
+
status: "unknown",
|
|
244
|
+
});
|
|
245
|
+
expect(() => reconciliation.admit("prompt", "capacity-ref-1")).not.toThrow();
|
|
246
|
+
const retained = {
|
|
247
|
+
commandId: `capacity-command-${TERMINAL_CAPACITY + 1}`,
|
|
248
|
+
turnId: `capacity-turn-${TERMINAL_CAPACITY + 1}`,
|
|
249
|
+
};
|
|
250
|
+
expect(reconciliation.lookup("prompt", retained)).toMatchObject({ status: "terminal_ok" });
|
|
251
|
+
});
|
|
174
252
|
|
|
175
253
|
describe("SessionSdkSessionRuntime", () => {
|
|
176
254
|
test("has no notification adapter or native notification import edge", async () => {
|
|
@@ -180,39 +258,1912 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
180
258
|
expect(source).not.toContain("NotificationServer");
|
|
181
259
|
});
|
|
182
260
|
|
|
183
|
-
test("hosts control, replay, and reverse frames with notifications disabled", async () => {
|
|
261
|
+
test("hosts control, replay, and reverse frames with notifications disabled", async () => {
|
|
262
|
+
const transport = memoryTransport();
|
|
263
|
+
const runtime = new SessionSdkSessionRuntime({
|
|
264
|
+
transport,
|
|
265
|
+
control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
|
|
266
|
+
query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
|
|
267
|
+
});
|
|
268
|
+
await runtime.start();
|
|
269
|
+
runtime.emitEvent({ kind: "session_ready", sessionId: transport.sessionId });
|
|
270
|
+
transport.feed("client", {
|
|
271
|
+
type: "event_replay",
|
|
272
|
+
id: "replay",
|
|
273
|
+
sinceGeneration: runtime.generation,
|
|
274
|
+
sinceSeq: 0,
|
|
275
|
+
});
|
|
276
|
+
transport.feed("client", {
|
|
277
|
+
type: "control_request",
|
|
278
|
+
id: "control",
|
|
279
|
+
operation: "runtime.capabilities",
|
|
280
|
+
input: {},
|
|
281
|
+
});
|
|
282
|
+
transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
|
|
283
|
+
await Bun.sleep(0);
|
|
284
|
+
expect(transport.broadcasts.some(frame => frame.kind === "session_ready")).toBe(true);
|
|
285
|
+
expect(transport.sent).toEqual(
|
|
286
|
+
expect.arrayContaining([
|
|
287
|
+
expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
|
|
288
|
+
expect.objectContaining({ type: "control_response", id: "control", ok: true }),
|
|
289
|
+
expect.objectContaining({ type: "query_response", id: "query", ok: true }),
|
|
290
|
+
]),
|
|
291
|
+
);
|
|
292
|
+
await runtime.stop();
|
|
293
|
+
});
|
|
294
|
+
test("SDK-only host admits, replays, and conflicts terminal abort requests durably", async () => {
|
|
295
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-abort-"));
|
|
296
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
297
|
+
const api = {
|
|
298
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
299
|
+
handlers.set(event, handler);
|
|
300
|
+
},
|
|
301
|
+
} as unknown as ExtensionAPI;
|
|
302
|
+
const transport = memoryTransport();
|
|
303
|
+
const reconciliationStore = createReconciliationStore({
|
|
304
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
305
|
+
sessionId: transport.sessionId,
|
|
306
|
+
});
|
|
307
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
308
|
+
let activeHandle: string | undefined = "exact-run-handle";
|
|
309
|
+
let activeEpoch: number | undefined = 7;
|
|
310
|
+
let captureCalls = 0;
|
|
311
|
+
createSdkSessionRuntimeExtension(api, {
|
|
312
|
+
agentDir: cwd,
|
|
313
|
+
createTransport: async () => transport,
|
|
314
|
+
onSdkRequest: undefined,
|
|
315
|
+
terminalAbortSeams: {
|
|
316
|
+
getReconciliationStore: () => reconciliationStore,
|
|
317
|
+
getTerminalTurnEpoch: () => activeEpoch,
|
|
318
|
+
getActivePromptHandle: () => activeHandle,
|
|
319
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
320
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
321
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
322
|
+
captureCalls += 1;
|
|
323
|
+
return captureCalls;
|
|
324
|
+
},
|
|
325
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
326
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
327
|
+
return { status: "settled", terminalScope: {} };
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
332
|
+
try {
|
|
333
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
334
|
+
const waitForFrame = async (id: string) => {
|
|
335
|
+
const deadline = Date.now() + 15_000;
|
|
336
|
+
while (!transport.sent.some(frame => frame.id === id)) {
|
|
337
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for control frame ${id}`);
|
|
338
|
+
await Bun.sleep(20);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const request = {
|
|
342
|
+
type: "control_request",
|
|
343
|
+
id: "terminal-abort-1",
|
|
344
|
+
operation: "turn.abort",
|
|
345
|
+
input: { mode: "terminal" },
|
|
346
|
+
idempotencyKey: "terminal-key-1",
|
|
347
|
+
} as SdkFrame;
|
|
348
|
+
transport.feed("client", request);
|
|
349
|
+
await waitForFrame("terminal-abort-1");
|
|
350
|
+
// The steering snapshot seam is captured at ADMISSION (before the
|
|
351
|
+
// durable transaction and settlement) — mirrors the production
|
|
352
|
+
// wiring in session.ts (review thread P1).
|
|
353
|
+
expect(captureCalls).toBeGreaterThanOrEqual(1);
|
|
354
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
|
|
355
|
+
expect(transport.sent).toEqual(
|
|
356
|
+
expect.arrayContaining([
|
|
357
|
+
expect.objectContaining({
|
|
358
|
+
type: "control_response",
|
|
359
|
+
id: "terminal-abort-1",
|
|
360
|
+
ok: true,
|
|
361
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
362
|
+
}),
|
|
363
|
+
]),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
transport.feed("client", { ...request, id: "terminal-abort-replay" });
|
|
367
|
+
await waitForFrame("terminal-abort-replay");
|
|
368
|
+
// An in-memory dispatch-cache replay short-circuits before the
|
|
369
|
+
// surface: terminalAbort never runs, so no snapshot is captured and
|
|
370
|
+
// nothing to discard (the durable-replay discard is covered by the
|
|
371
|
+
// seeded-row test below).
|
|
372
|
+
expect(seamCalls).toHaveLength(1);
|
|
373
|
+
expect(transport.sent).toEqual(
|
|
374
|
+
expect.arrayContaining([
|
|
375
|
+
expect.objectContaining({
|
|
376
|
+
type: "control_response",
|
|
377
|
+
id: "terminal-abort-replay",
|
|
378
|
+
ok: true,
|
|
379
|
+
}),
|
|
380
|
+
]),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
transport.feed("client", {
|
|
384
|
+
...request,
|
|
385
|
+
id: "terminal-abort-conflict",
|
|
386
|
+
input: { mode: "terminal", scope: "owned" },
|
|
387
|
+
});
|
|
388
|
+
await waitForFrame("terminal-abort-conflict");
|
|
389
|
+
expect(seamCalls).toHaveLength(1);
|
|
390
|
+
expect(transport.sent).toEqual(
|
|
391
|
+
expect.arrayContaining([
|
|
392
|
+
expect.objectContaining({
|
|
393
|
+
type: "control_response",
|
|
394
|
+
id: "terminal-abort-conflict",
|
|
395
|
+
ok: false,
|
|
396
|
+
error: expect.objectContaining({ code: "idempotency_conflict" }),
|
|
397
|
+
}),
|
|
398
|
+
]),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
activeHandle = undefined;
|
|
402
|
+
activeEpoch = undefined;
|
|
403
|
+
const idleRequest = { ...request, id: "terminal-abort-idle", idempotencyKey: "terminal-idle-key" };
|
|
404
|
+
transport.feed("client", idleRequest);
|
|
405
|
+
await waitForFrame("terminal-abort-idle");
|
|
406
|
+
expect(seamCalls).toHaveLength(1);
|
|
407
|
+
activeHandle = "later-run-handle";
|
|
408
|
+
activeEpoch = 8;
|
|
409
|
+
transport.feed("client", { ...idleRequest, id: "terminal-abort-idle-replay" });
|
|
410
|
+
await waitForFrame("terminal-abort-idle-replay");
|
|
411
|
+
expect(seamCalls).toHaveLength(1);
|
|
412
|
+
expect(transport.sent).toEqual(
|
|
413
|
+
expect.arrayContaining([
|
|
414
|
+
expect.objectContaining({
|
|
415
|
+
type: "control_response",
|
|
416
|
+
id: "terminal-abort-idle-replay",
|
|
417
|
+
ok: true,
|
|
418
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
419
|
+
}),
|
|
420
|
+
]),
|
|
421
|
+
);
|
|
422
|
+
} finally {
|
|
423
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
424
|
+
await rm(cwd, { recursive: true, force: true });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("SDK-only host correlates a steering-queued prompt when the unwind promotes it to a run", async () => {
|
|
429
|
+
// Review thread P2: a prompt accepted while the session reports busy
|
|
430
|
+
// (finished prompt unwinding) is queued as steering with NO pending entry
|
|
431
|
+
// at accept; when the unwind continuation actually promotes it to its own
|
|
432
|
+
// run, the promotion hook must create the ownership correlation so the
|
|
433
|
+
// submitting connection can terminal-abort that turn.
|
|
434
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-unwind-promoted-"));
|
|
435
|
+
let idle = true;
|
|
436
|
+
let promoted: (() => void) | undefined;
|
|
437
|
+
const queuedDispositions: boolean[] = [];
|
|
438
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
439
|
+
const api = {
|
|
440
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
441
|
+
handlers.set(event, handler);
|
|
442
|
+
},
|
|
443
|
+
sendUserMessage: (
|
|
444
|
+
_content: string,
|
|
445
|
+
options:
|
|
446
|
+
| {
|
|
447
|
+
onPreflightAccepted?: () => void;
|
|
448
|
+
onPreflightAcceptCommit?: () => void;
|
|
449
|
+
onQueuedPromoted?: () => void;
|
|
450
|
+
queuedAtDispatch?: boolean;
|
|
451
|
+
}
|
|
452
|
+
| undefined,
|
|
453
|
+
) =>
|
|
454
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
455
|
+
queuedDispositions.push(options?.queuedAtDispatch === true);
|
|
456
|
+
promoted = options?.onQueuedPromoted;
|
|
457
|
+
options?.onPreflightAccepted?.();
|
|
458
|
+
return {};
|
|
459
|
+
}),
|
|
460
|
+
} as unknown as ExtensionAPI;
|
|
461
|
+
const transport = memoryTransport();
|
|
462
|
+
const reconciliationStore = createReconciliationStore({
|
|
463
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
464
|
+
sessionId: transport.sessionId,
|
|
465
|
+
});
|
|
466
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
467
|
+
let captureCalls = 0;
|
|
468
|
+
createSdkSessionRuntimeExtension(api, {
|
|
469
|
+
agentDir: cwd,
|
|
470
|
+
createTransport: async () => transport,
|
|
471
|
+
terminalAbortSeams: {
|
|
472
|
+
getReconciliationStore: () => reconciliationStore,
|
|
473
|
+
getTerminalTurnEpoch: () => 7,
|
|
474
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
475
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
476
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
477
|
+
captureCalls += 1;
|
|
478
|
+
return captureCalls;
|
|
479
|
+
},
|
|
480
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
481
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
482
|
+
return { status: "settled", terminalScope: {} };
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
const ctx = {
|
|
487
|
+
...extensionContext(transport.sessionId, cwd),
|
|
488
|
+
isIdle: () => idle,
|
|
489
|
+
} as unknown as ExtensionContext;
|
|
490
|
+
try {
|
|
491
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
492
|
+
const prompt = (connectionId: string, id: string) =>
|
|
493
|
+
transport.feed(connectionId, {
|
|
494
|
+
type: "control_request",
|
|
495
|
+
id,
|
|
496
|
+
operation: "turn.prompt",
|
|
497
|
+
input: { text: id, images: [] },
|
|
498
|
+
} as SdkFrame);
|
|
499
|
+
const waitResponse = async (id: string) => {
|
|
500
|
+
const deadline = Date.now() + 15_000;
|
|
501
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
502
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
503
|
+
await Bun.sleep(20);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
// A submits while idle and STARTS its run: owner becomes conn-a.
|
|
507
|
+
prompt("conn-a", "unwind-a");
|
|
508
|
+
await waitResponse("unwind-a");
|
|
509
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
510
|
+
// B submits while the session still reports busy (unwinding): the prompt
|
|
511
|
+
// is queued as steering and NO pending entry is created at accept.
|
|
512
|
+
idle = false;
|
|
513
|
+
prompt("conn-b", "unwind-b");
|
|
514
|
+
await waitResponse("unwind-b");
|
|
515
|
+
expect(queuedDispositions).toEqual([false, true]);
|
|
516
|
+
expect(promoted).toBeDefined();
|
|
517
|
+
// The unwind continuation promotes the queued steer to its own run: the
|
|
518
|
+
// correlation hook fires before agent_start...
|
|
519
|
+
promoted!();
|
|
520
|
+
// ...and B's run starts: ownership transfers to conn-b.
|
|
521
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
522
|
+
// B's own terminal abort now stops its turn (previously no_active_turn).
|
|
523
|
+
transport.feed("conn-b", {
|
|
524
|
+
type: "control_request",
|
|
525
|
+
id: "unwind-abort",
|
|
526
|
+
operation: "turn.abort",
|
|
527
|
+
input: { mode: "terminal" },
|
|
528
|
+
idempotencyKey: "unwind-abort-key",
|
|
529
|
+
} as SdkFrame);
|
|
530
|
+
await waitResponse("unwind-abort");
|
|
531
|
+
expect(transport.sent.find(frame => frame.id === "unwind-abort")).toMatchObject({
|
|
532
|
+
ok: true,
|
|
533
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
534
|
+
});
|
|
535
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
|
|
536
|
+
} finally {
|
|
537
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
538
|
+
await rm(cwd, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("SDK-only host re-reads the active prompt after an idle reservation wins the race", async () => {
|
|
543
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-race-active-"));
|
|
544
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
545
|
+
const api = {
|
|
546
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
547
|
+
handlers.set(event, handler);
|
|
548
|
+
},
|
|
549
|
+
} as unknown as ExtensionAPI;
|
|
550
|
+
const transport = memoryTransport();
|
|
551
|
+
const reconciliationStore = createReconciliationStore({
|
|
552
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
553
|
+
sessionId: transport.sessionId,
|
|
554
|
+
});
|
|
555
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
556
|
+
// The FIRST active-prompt read sees no turn (idle abort request); a
|
|
557
|
+
// prompt then wins the race and every later read sees it active —
|
|
558
|
+
// exactly the window where writeNoEffect awaits the store.
|
|
559
|
+
let promptReads = 0;
|
|
560
|
+
let captureCalls = 0;
|
|
561
|
+
createSdkSessionRuntimeExtension(api, {
|
|
562
|
+
agentDir: cwd,
|
|
563
|
+
createTransport: async () => transport,
|
|
564
|
+
terminalAbortSeams: {
|
|
565
|
+
getReconciliationStore: () => reconciliationStore,
|
|
566
|
+
getTerminalTurnEpoch: () => (promptReads++ === 0 ? undefined : 7),
|
|
567
|
+
getActivePromptHandle: () => (promptReads === 0 ? undefined : "won-race-handle"),
|
|
568
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
569
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
570
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
571
|
+
captureCalls += 1;
|
|
572
|
+
return captureCalls;
|
|
573
|
+
},
|
|
574
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
575
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
576
|
+
return {
|
|
577
|
+
status: "settled",
|
|
578
|
+
terminalScope: { scopeId: "scope-race", abortedAttemptEpoch: 7, lineageIdHash: "race-lineage" },
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
584
|
+
try {
|
|
585
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
586
|
+
transport.feed("client", {
|
|
587
|
+
type: "control_request",
|
|
588
|
+
id: "race-abort",
|
|
589
|
+
operation: "turn.abort",
|
|
590
|
+
input: { mode: "terminal" },
|
|
591
|
+
idempotencyKey: "race-key",
|
|
592
|
+
} as SdkFrame);
|
|
593
|
+
const deadline = Date.now() + 15_000;
|
|
594
|
+
while (!transport.sent.some(frame => frame.id === "race-abort")) {
|
|
595
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the race abort response");
|
|
596
|
+
await Bun.sleep(20);
|
|
597
|
+
}
|
|
598
|
+
// The prompt that won the race is ACTIVE-terminalized, never reported
|
|
599
|
+
// as no_active_turn, and the durable no-effect reservation was replaced
|
|
600
|
+
// by the stopped marker so a same-key retry replays stopped.
|
|
601
|
+
expect(seamCalls).toEqual([{ handle: "won-race-handle", scope: "turn" }]);
|
|
602
|
+
expect(transport.sent.find(frame => frame.id === "race-abort")).toMatchObject({
|
|
603
|
+
ok: true,
|
|
604
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
605
|
+
});
|
|
606
|
+
transport.feed("client", {
|
|
607
|
+
type: "control_request",
|
|
608
|
+
id: "race-replay",
|
|
609
|
+
operation: "turn.abort",
|
|
610
|
+
input: { mode: "terminal" },
|
|
611
|
+
idempotencyKey: "race-key",
|
|
612
|
+
} as SdkFrame);
|
|
613
|
+
const replayDeadline = Date.now() + 15_000;
|
|
614
|
+
while (!transport.sent.some(frame => frame.id === "race-replay")) {
|
|
615
|
+
if (Date.now() > replayDeadline) throw new Error("Timed out waiting for the race replay");
|
|
616
|
+
await Bun.sleep(20);
|
|
617
|
+
}
|
|
618
|
+
expect(transport.sent.find(frame => frame.id === "race-replay")).toMatchObject({
|
|
619
|
+
ok: true,
|
|
620
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
621
|
+
});
|
|
622
|
+
expect(seamCalls).toHaveLength(1);
|
|
623
|
+
} finally {
|
|
624
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
625
|
+
await rm(cwd, { recursive: true, force: true });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("SDK-only host re-reads the active prompt AND its owner after an owner-mismatch reservation wins the race", async () => {
|
|
630
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-owner-race-"));
|
|
631
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
632
|
+
const api = {
|
|
633
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
634
|
+
handlers.set(event, handler);
|
|
635
|
+
},
|
|
636
|
+
} as unknown as ExtensionAPI;
|
|
637
|
+
const transport = memoryTransport();
|
|
638
|
+
const reconciliationStore = createReconciliationStore({
|
|
639
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
640
|
+
sessionId: transport.sessionId,
|
|
641
|
+
});
|
|
642
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
643
|
+
// Connection A owns the active prompt when the abort is read; while the
|
|
644
|
+
// no-effect reservation awaits the store, A's prompt finishes and
|
|
645
|
+
// connection B's pending prompt wins the race. The aborting connection
|
|
646
|
+
// (B) must re-read handle, epoch, AND owner and fall through to ACTIVE
|
|
647
|
+
// terminalization instead of returning no_active_turn and leaving a
|
|
648
|
+
// durable reservation that blocks B's own running prompt forever
|
|
649
|
+
// (review thread P1).
|
|
650
|
+
let reads = 0;
|
|
651
|
+
let captureCalls = 0;
|
|
652
|
+
createSdkSessionRuntimeExtension(api, {
|
|
653
|
+
agentDir: cwd,
|
|
654
|
+
createTransport: async () => transport,
|
|
655
|
+
terminalAbortSeams: {
|
|
656
|
+
getReconciliationStore: () => reconciliationStore,
|
|
657
|
+
getActivePromptHandle: () => {
|
|
658
|
+
reads += 1;
|
|
659
|
+
return reads === 1 ? "owner-a-handle" : "winner-b-handle";
|
|
660
|
+
},
|
|
661
|
+
getTerminalTurnEpoch: () => (reads === 1 ? 1 : 2),
|
|
662
|
+
getActivePromptOwnerConnectionId: () => (reads === 1 ? "conn-a" : "conn-b"),
|
|
663
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
664
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
665
|
+
captureCalls += 1;
|
|
666
|
+
return captureCalls;
|
|
667
|
+
},
|
|
668
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
669
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
670
|
+
return {
|
|
671
|
+
status: "settled",
|
|
672
|
+
terminalScope: {
|
|
673
|
+
scopeId: "scope-owner-race",
|
|
674
|
+
abortedAttemptEpoch: 2,
|
|
675
|
+
lineageIdHash: "owner-lineage",
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
682
|
+
try {
|
|
683
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
684
|
+
// The aborting connection is "conn-b"; the first owner read is
|
|
685
|
+
// "conn-a", so this enters the owner-mismatch branch.
|
|
686
|
+
transport.feed("conn-b", {
|
|
687
|
+
type: "control_request",
|
|
688
|
+
id: "owner-race-abort",
|
|
689
|
+
operation: "turn.abort",
|
|
690
|
+
input: { mode: "terminal" },
|
|
691
|
+
idempotencyKey: "owner-race-key",
|
|
692
|
+
} as SdkFrame);
|
|
693
|
+
const deadline = Date.now() + 15_000;
|
|
694
|
+
while (!transport.sent.some(frame => frame.id === "owner-race-abort")) {
|
|
695
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the owner race abort response");
|
|
696
|
+
await Bun.sleep(20);
|
|
697
|
+
}
|
|
698
|
+
// B's now-owned prompt is ACTIVE-terminalized with B's run handle,
|
|
699
|
+
// never reported no_active_turn.
|
|
700
|
+
expect(seamCalls).toEqual([{ handle: "winner-b-handle", scope: "turn" }]);
|
|
701
|
+
expect(transport.sent.find(frame => frame.id === "owner-race-abort")).toMatchObject({
|
|
702
|
+
ok: true,
|
|
703
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
704
|
+
});
|
|
705
|
+
// The durable no-effect reservation was replaced by the stopped
|
|
706
|
+
// marker, so a same-key retry replays stopped instead of
|
|
707
|
+
// no_active_turn.
|
|
708
|
+
transport.feed("conn-b", {
|
|
709
|
+
type: "control_request",
|
|
710
|
+
id: "owner-race-replay",
|
|
711
|
+
operation: "turn.abort",
|
|
712
|
+
input: { mode: "terminal" },
|
|
713
|
+
idempotencyKey: "owner-race-key",
|
|
714
|
+
} as SdkFrame);
|
|
715
|
+
const replayDeadline = Date.now() + 15_000;
|
|
716
|
+
while (!transport.sent.some(frame => frame.id === "owner-race-replay")) {
|
|
717
|
+
if (Date.now() > replayDeadline) throw new Error("Timed out waiting for the owner race replay");
|
|
718
|
+
await Bun.sleep(20);
|
|
719
|
+
}
|
|
720
|
+
expect(transport.sent.find(frame => frame.id === "owner-race-replay")).toMatchObject({
|
|
721
|
+
ok: true,
|
|
722
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
723
|
+
});
|
|
724
|
+
expect(seamCalls).toHaveLength(1);
|
|
725
|
+
} finally {
|
|
726
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
727
|
+
await rm(cwd, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("SDK-only host records the OBSERVED agent_end publication on the terminal stopped row", async () => {
|
|
732
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-published-"));
|
|
733
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
734
|
+
const api = {
|
|
735
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
736
|
+
handlers.set(event, handler);
|
|
737
|
+
},
|
|
738
|
+
} as unknown as ExtensionAPI;
|
|
739
|
+
const transport = memoryTransport();
|
|
740
|
+
const reconciliationStore = createReconciliationStore({
|
|
741
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
742
|
+
sessionId: transport.sessionId,
|
|
743
|
+
});
|
|
744
|
+
let captureCalls = 0;
|
|
745
|
+
createSdkSessionRuntimeExtension(api, {
|
|
746
|
+
agentDir: cwd,
|
|
747
|
+
createTransport: async () => transport,
|
|
748
|
+
terminalAbortSeams: {
|
|
749
|
+
getReconciliationStore: () => reconciliationStore,
|
|
750
|
+
getTerminalTurnEpoch: () => 7,
|
|
751
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
752
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
753
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
754
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
755
|
+
captureCalls += 1;
|
|
756
|
+
return captureCalls;
|
|
757
|
+
},
|
|
758
|
+
abortPromptAndWaitWithTerminal: async (_handle, _options) => {
|
|
759
|
+
// The aborted run's loop exit publishes the correlated
|
|
760
|
+
// agent_end lifecycle event before settlement returns.
|
|
761
|
+
await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
|
|
762
|
+
return { status: "settled", terminalScope: {} };
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
767
|
+
try {
|
|
768
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
769
|
+
transport.feed("client", {
|
|
770
|
+
type: "control_request",
|
|
771
|
+
id: "published-abort",
|
|
772
|
+
operation: "turn.abort",
|
|
773
|
+
input: { mode: "terminal" },
|
|
774
|
+
idempotencyKey: "published-key",
|
|
775
|
+
} as SdkFrame);
|
|
776
|
+
const deadline = Date.now() + 15_000;
|
|
777
|
+
while (!transport.sent.some(frame => frame.id === "published-abort")) {
|
|
778
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the published abort response");
|
|
779
|
+
await Bun.sleep(20);
|
|
780
|
+
}
|
|
781
|
+
expect(transport.sent.find(frame => frame.id === "published-abort")).toMatchObject({
|
|
782
|
+
ok: true,
|
|
783
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
784
|
+
});
|
|
785
|
+
// The correlated agent_end was OBSERVED, so the durable row claims
|
|
786
|
+
// terminalPublished (AC 19) — never assumed.
|
|
787
|
+
const scopes = reconciliationStore.snapshotTerminalScopes();
|
|
788
|
+
expect(scopes).toHaveLength(1);
|
|
789
|
+
expect(scopes[0]).toMatchObject({ turnDisposition: "stopped", terminalPublished: true });
|
|
790
|
+
} finally {
|
|
791
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
792
|
+
await rm(cwd, { recursive: true, force: true });
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("SDK-only host resolves EVERY concurrent terminal-publication waiter for one aborted turn", async () => {
|
|
797
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-concurrent-publish-"));
|
|
798
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
799
|
+
const api = {
|
|
800
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
801
|
+
handlers.set(event, handler);
|
|
802
|
+
},
|
|
803
|
+
} as unknown as ExtensionAPI;
|
|
804
|
+
const transport = memoryTransport();
|
|
805
|
+
const reconciliationStore = createReconciliationStore({
|
|
806
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
807
|
+
sessionId: transport.sessionId,
|
|
808
|
+
});
|
|
809
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
810
|
+
let seamCount = 0;
|
|
811
|
+
let captureCalls = 0;
|
|
812
|
+
createSdkSessionRuntimeExtension(api, {
|
|
813
|
+
agentDir: cwd,
|
|
814
|
+
createTransport: async () => transport,
|
|
815
|
+
terminalAbortSeams: {
|
|
816
|
+
getReconciliationStore: () => reconciliationStore,
|
|
817
|
+
getTerminalTurnEpoch: () => 7,
|
|
818
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
819
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
820
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
821
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
822
|
+
captureCalls += 1;
|
|
823
|
+
return captureCalls;
|
|
824
|
+
},
|
|
825
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
826
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
827
|
+
// The turn emits exactly ONE correlated agent_end no matter how
|
|
828
|
+
// many concurrent aborts stop it; both waiters are installed just
|
|
829
|
+
// before their seam call, so fire it once BOTH have landed.
|
|
830
|
+
seamCount += 1;
|
|
831
|
+
if (seamCount === 2) {
|
|
832
|
+
await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
|
|
833
|
+
}
|
|
834
|
+
return { status: "settled", terminalScope: {} };
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
839
|
+
try {
|
|
840
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
841
|
+
const abort = (id: string, idempotencyKey: string) =>
|
|
842
|
+
transport.feed("client", {
|
|
843
|
+
type: "control_request",
|
|
844
|
+
id,
|
|
845
|
+
operation: "turn.abort",
|
|
846
|
+
input: { mode: "terminal" },
|
|
847
|
+
idempotencyKey,
|
|
848
|
+
} as SdkFrame);
|
|
849
|
+
// Two concurrent aborts of the SAME active turn with DISTINCT keys:
|
|
850
|
+
// both are admitted and both await the single agent_end.
|
|
851
|
+
abort("conc-abort-1", "conc-key-1");
|
|
852
|
+
abort("conc-abort-2", "conc-key-2");
|
|
853
|
+
const deadline = Date.now() + 15_000;
|
|
854
|
+
while (
|
|
855
|
+
!transport.sent.some(frame => frame.id === "conc-abort-1" && frame.type === "control_response") ||
|
|
856
|
+
!transport.sent.some(frame => frame.id === "conc-abort-2" && frame.type === "control_response")
|
|
857
|
+
) {
|
|
858
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the concurrent abort responses");
|
|
859
|
+
await Bun.sleep(20);
|
|
860
|
+
}
|
|
861
|
+
// The onControlResponseDelivery observer writes responseState asynchronously
|
|
862
|
+
// after the transport send; wait for BOTH durable rows to settle before
|
|
863
|
+
// asserting, so the test is not racy on the observer's transactTerminalState
|
|
864
|
+
// microtask drain.
|
|
865
|
+
while (
|
|
866
|
+
reconciliationStore.snapshotTerminalScopes().length < 2 ||
|
|
867
|
+
reconciliationStore.snapshotTerminalScopes().some(scope => scope.responseState !== "sent")
|
|
868
|
+
) {
|
|
869
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for durable responseState to settle");
|
|
870
|
+
await Bun.sleep(20);
|
|
871
|
+
}
|
|
872
|
+
expect(transport.sent.find(frame => frame.id === "conc-abort-1")).toMatchObject({
|
|
873
|
+
ok: true,
|
|
874
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
875
|
+
});
|
|
876
|
+
expect(transport.sent.find(frame => frame.id === "conc-abort-2")).toMatchObject({
|
|
877
|
+
ok: true,
|
|
878
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
879
|
+
});
|
|
880
|
+
// BOTH durable rows observed the single publication — a latest-wins
|
|
881
|
+
// single slot would leave one of them terminalPublished:false (review
|
|
882
|
+
// thread P2).
|
|
883
|
+
const scopes = reconciliationStore.snapshotTerminalScopes();
|
|
884
|
+
expect(scopes).toHaveLength(2);
|
|
885
|
+
for (const scope of scopes) {
|
|
886
|
+
expect(scope).toMatchObject({ turnDisposition: "stopped", terminalPublished: true });
|
|
887
|
+
// The stopped result was actually written: the delivery observer
|
|
888
|
+
// matched the response payload hash (which must include the public
|
|
889
|
+
// `ok` field) and advanced the durable state — a shape mismatch
|
|
890
|
+
// would leave responseState pending despite the successful write
|
|
891
|
+
// (review thread P2).
|
|
892
|
+
expect(scope.responseState).toBe("sent");
|
|
893
|
+
}
|
|
894
|
+
} finally {
|
|
895
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
896
|
+
await rm(cwd, { recursive: true, force: true });
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("SDK-only host never claims terminalPublished without observing the agent_end publication", async () => {
|
|
901
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-unpublished-"));
|
|
902
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
903
|
+
const api = {
|
|
904
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
905
|
+
handlers.set(event, handler);
|
|
906
|
+
},
|
|
907
|
+
} as unknown as ExtensionAPI;
|
|
908
|
+
const transport = memoryTransport();
|
|
909
|
+
const reconciliationStore = createReconciliationStore({
|
|
910
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
911
|
+
sessionId: transport.sessionId,
|
|
912
|
+
});
|
|
913
|
+
createSdkSessionRuntimeExtension(api, {
|
|
914
|
+
agentDir: cwd,
|
|
915
|
+
createTransport: async () => transport,
|
|
916
|
+
terminalAbortSeams: {
|
|
917
|
+
getReconciliationStore: () => reconciliationStore,
|
|
918
|
+
getTerminalTurnEpoch: () => 7,
|
|
919
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
920
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
921
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
922
|
+
abortPromptAndWaitWithTerminal: async (_handle, _options) => {
|
|
923
|
+
// The worker settles but the lifecycle listener never completes:
|
|
924
|
+
// the durable row must NOT claim the terminal event reached
|
|
925
|
+
// clients (review thread P2).
|
|
926
|
+
return { status: "settled", terminalScope: {} };
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
931
|
+
try {
|
|
932
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
933
|
+
transport.feed("client", {
|
|
934
|
+
type: "control_request",
|
|
935
|
+
id: "unpublished-abort",
|
|
936
|
+
operation: "turn.abort",
|
|
937
|
+
input: { mode: "terminal" },
|
|
938
|
+
idempotencyKey: "unpublished-key",
|
|
939
|
+
} as SdkFrame);
|
|
940
|
+
const deadline = Date.now() + 15_000;
|
|
941
|
+
while (!transport.sent.some(frame => frame.id === "unpublished-abort")) {
|
|
942
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the unpublished abort response");
|
|
943
|
+
await Bun.sleep(20);
|
|
944
|
+
}
|
|
945
|
+
expect(transport.sent.find(frame => frame.id === "unpublished-abort")).toMatchObject({
|
|
946
|
+
ok: true,
|
|
947
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
948
|
+
});
|
|
949
|
+
// No agent_end was observed: the durable stopped row reports
|
|
950
|
+
// terminalPublished:false, the fail-safe direction.
|
|
951
|
+
const scopes = reconciliationStore.snapshotTerminalScopes();
|
|
952
|
+
expect(scopes).toHaveLength(1);
|
|
953
|
+
expect(scopes[0]).toMatchObject({ turnDisposition: "stopped", terminalPublished: false });
|
|
954
|
+
} finally {
|
|
955
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
956
|
+
await rm(cwd, { recursive: true, force: true });
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("SDK-only host cancels only the preflights admitted at abort time, never a pipelined successor", async () => {
|
|
961
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-preflight-snapshot-"));
|
|
962
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
963
|
+
const api = {
|
|
964
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
965
|
+
handlers.set(event, handler);
|
|
966
|
+
},
|
|
967
|
+
// Hold every prompt preflight open: acceptance is never signalled, so
|
|
968
|
+
// each submission stays pending until the abort (or nothing) settles it.
|
|
969
|
+
// Deferred via Promise.withResolvers per the repository contract.
|
|
970
|
+
sendUserMessage: () => Promise.withResolvers<void>().promise,
|
|
971
|
+
} as unknown as ExtensionAPI;
|
|
972
|
+
const transport = memoryTransport();
|
|
973
|
+
const reconciliationStore = createReconciliationStore({
|
|
974
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
975
|
+
sessionId: transport.sessionId,
|
|
976
|
+
});
|
|
977
|
+
createSdkSessionRuntimeExtension(api, {
|
|
978
|
+
agentDir: cwd,
|
|
979
|
+
createTransport: async () => transport,
|
|
980
|
+
terminalAbortSeams: {
|
|
981
|
+
getReconciliationStore: () => reconciliationStore,
|
|
982
|
+
// No active turn: the abort takes the idle no-effect path, which is
|
|
983
|
+
// where cancelRequesterPreflights runs after the durable reservation.
|
|
984
|
+
getTerminalTurnEpoch: () => undefined,
|
|
985
|
+
getActivePromptHandle: () => undefined,
|
|
986
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
987
|
+
abortPromptAndWaitWithTerminal: async (_handle, _options) => {
|
|
988
|
+
throw new Error("seam must not be called for an idle abort");
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
993
|
+
try {
|
|
994
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
995
|
+
const prompt = (id: string) =>
|
|
996
|
+
transport.feed("conn-a", {
|
|
997
|
+
type: "control_request",
|
|
998
|
+
id,
|
|
999
|
+
operation: "turn.prompt",
|
|
1000
|
+
input: { text: `hold-${id}`, images: [] },
|
|
1001
|
+
} as SdkFrame);
|
|
1002
|
+
prompt("pre-1");
|
|
1003
|
+
// Let the serialized (ordered) turn.prompt work register its preflight
|
|
1004
|
+
// callback, so the abort's admission snapshot below sees it.
|
|
1005
|
+
await Bun.sleep(0);
|
|
1006
|
+
transport.feed("conn-a", {
|
|
1007
|
+
type: "control_request",
|
|
1008
|
+
id: "pre-abort",
|
|
1009
|
+
operation: "turn.abort",
|
|
1010
|
+
input: { mode: "terminal" },
|
|
1011
|
+
idempotencyKey: "pre-snapshot-key",
|
|
1012
|
+
} as SdkFrame);
|
|
1013
|
+
// A successor prompt pipelined by the SAME connection while the abort
|
|
1014
|
+
// awaits the reconciliation transaction: its preflight lands in the
|
|
1015
|
+
// live bucket AFTER the abort's admission snapshot and must survive.
|
|
1016
|
+
prompt("pre-2");
|
|
1017
|
+
const deadline = Date.now() + 15_000;
|
|
1018
|
+
while (
|
|
1019
|
+
!transport.sent.some(frame => frame.id === "pre-abort" && frame.type === "control_response") ||
|
|
1020
|
+
!transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response")
|
|
1021
|
+
) {
|
|
1022
|
+
if (Date.now() > deadline)
|
|
1023
|
+
throw new Error("Timed out waiting for the abort and admitted-preflight responses");
|
|
1024
|
+
await Bun.sleep(20);
|
|
1025
|
+
}
|
|
1026
|
+
// The admitted preflight is cancelled by the abort.
|
|
1027
|
+
expect(transport.sent.find(frame => frame.id === "pre-1")).toMatchObject({
|
|
1028
|
+
ok: false,
|
|
1029
|
+
error: expect.objectContaining({ code: "busy" }),
|
|
1030
|
+
});
|
|
1031
|
+
expect(transport.sent.find(frame => frame.id === "pre-abort")).toMatchObject({
|
|
1032
|
+
ok: true,
|
|
1033
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
1034
|
+
});
|
|
1035
|
+
// The successor preflight was NOT part of the abort's snapshot: it is
|
|
1036
|
+
// neither cancelled nor failed, so no control response is emitted for it.
|
|
1037
|
+
expect(transport.sent.some(frame => frame.id === "pre-2" && frame.type === "control_response")).toBe(false);
|
|
1038
|
+
} finally {
|
|
1039
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1040
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test("SDK-only host tracks ownership for skill-invoked turns so a foreign abort cannot stop them", async () => {
|
|
1045
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-skill-owner-"));
|
|
1046
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1047
|
+
const api = {
|
|
1048
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1049
|
+
handlers.set(event, handler);
|
|
1050
|
+
},
|
|
1051
|
+
} as unknown as ExtensionAPI;
|
|
1052
|
+
const transport = memoryTransport();
|
|
1053
|
+
const reconciliationStore = createReconciliationStore({
|
|
1054
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1055
|
+
sessionId: transport.sessionId,
|
|
1056
|
+
});
|
|
1057
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1058
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1059
|
+
agentDir: cwd,
|
|
1060
|
+
createTransport: async () => transport,
|
|
1061
|
+
terminalAbortSeams: {
|
|
1062
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1063
|
+
getTerminalTurnEpoch: () => 7,
|
|
1064
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1065
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1066
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1067
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1068
|
+
return { status: "settled", terminalScope: {} };
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
const ctx = {
|
|
1073
|
+
...extensionContext(transport.sessionId, cwd),
|
|
1074
|
+
// Declare the binding so the surface policy installs skill.invoke, and
|
|
1075
|
+
// accept the preflight so the skill turn is ADMITTED under conn-a.
|
|
1076
|
+
sdkBindings: () => ["invokeSkill"],
|
|
1077
|
+
invokeSkill: async (_name: string, _args: unknown, options: { onPreflightAccepted?: () => void }) => {
|
|
1078
|
+
options.onPreflightAccepted?.();
|
|
1079
|
+
return { accepted: true };
|
|
1080
|
+
},
|
|
1081
|
+
} as unknown as ExtensionContext;
|
|
1082
|
+
try {
|
|
1083
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1084
|
+
// Client A starts a skill: skill.invoke runs a real prompt through
|
|
1085
|
+
// submit(), so the ACCEPTING connection must own the active turn.
|
|
1086
|
+
transport.feed("conn-a", {
|
|
1087
|
+
type: "control_request",
|
|
1088
|
+
id: "skill-a",
|
|
1089
|
+
operation: "skill.invoke",
|
|
1090
|
+
input: { name: "some-skill", args: "" },
|
|
1091
|
+
} as SdkFrame);
|
|
1092
|
+
const skillDeadline = Date.now() + 15_000;
|
|
1093
|
+
while (!transport.sent.some(frame => frame.id === "skill-a" && frame.type === "control_response")) {
|
|
1094
|
+
if (Date.now() > skillDeadline) throw new Error("Timed out waiting for the skill acceptance");
|
|
1095
|
+
await Bun.sleep(20);
|
|
1096
|
+
}
|
|
1097
|
+
expect(transport.sent.find(frame => frame.id === "skill-a")).toMatchObject({
|
|
1098
|
+
ok: true,
|
|
1099
|
+
result: expect.objectContaining({ accepted: true }),
|
|
1100
|
+
});
|
|
1101
|
+
// Ownership is associated when the accepted submission STARTS its run
|
|
1102
|
+
// (agent_start), not at acceptance: fire the lifecycle event so the
|
|
1103
|
+
// skill run is owned by conn-a (review thread P1).
|
|
1104
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1105
|
+
// Client B's terminal abort must NOT stop A's skill run: owner is A.
|
|
1106
|
+
transport.feed("conn-b", {
|
|
1107
|
+
type: "control_request",
|
|
1108
|
+
id: "skill-abort-b",
|
|
1109
|
+
operation: "turn.abort",
|
|
1110
|
+
input: { mode: "terminal" },
|
|
1111
|
+
idempotencyKey: "skill-abort-b-key",
|
|
1112
|
+
} as SdkFrame);
|
|
1113
|
+
const abortDeadline = Date.now() + 15_000;
|
|
1114
|
+
while (!transport.sent.some(frame => frame.id === "skill-abort-b" && frame.type === "control_response")) {
|
|
1115
|
+
if (Date.now() > abortDeadline) throw new Error("Timed out waiting for the foreign abort response");
|
|
1116
|
+
await Bun.sleep(20);
|
|
1117
|
+
}
|
|
1118
|
+
expect(transport.sent.find(frame => frame.id === "skill-abort-b")).toMatchObject({
|
|
1119
|
+
ok: true,
|
|
1120
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
1121
|
+
});
|
|
1122
|
+
expect(seamCalls).toHaveLength(0);
|
|
1123
|
+
// A's own terminal abort still stops its skill run.
|
|
1124
|
+
transport.feed("conn-a", {
|
|
1125
|
+
type: "control_request",
|
|
1126
|
+
id: "skill-abort-a",
|
|
1127
|
+
operation: "turn.abort",
|
|
1128
|
+
input: { mode: "terminal" },
|
|
1129
|
+
idempotencyKey: "skill-abort-a-key",
|
|
1130
|
+
} as SdkFrame);
|
|
1131
|
+
const ownDeadline = Date.now() + 15_000;
|
|
1132
|
+
while (!transport.sent.some(frame => frame.id === "skill-abort-a" && frame.type === "control_response")) {
|
|
1133
|
+
if (Date.now() > ownDeadline) throw new Error("Timed out waiting for the owner abort response");
|
|
1134
|
+
await Bun.sleep(20);
|
|
1135
|
+
}
|
|
1136
|
+
expect(transport.sent.find(frame => frame.id === "skill-abort-a")).toMatchObject({
|
|
1137
|
+
ok: true,
|
|
1138
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
1139
|
+
});
|
|
1140
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
|
|
1141
|
+
} finally {
|
|
1142
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1143
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
test("SDK-only host does not transfer ownership to a queued submission until its run starts", async () => {
|
|
1148
|
+
// While client A's prompt is streaming, client B's prompt is accepted as
|
|
1149
|
+
// queued steering/follow-up: the owner must STAY with A until B's run
|
|
1150
|
+
// actually starts (agent_start), or B could terminal-abort A's running
|
|
1151
|
+
// turn while A is refused (review thread P1).
|
|
1152
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-queued-owner-"));
|
|
1153
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1154
|
+
const api = {
|
|
1155
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1156
|
+
handlers.set(event, handler);
|
|
1157
|
+
},
|
|
1158
|
+
sendUserMessage: (
|
|
1159
|
+
_content: string,
|
|
1160
|
+
options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
|
|
1161
|
+
) =>
|
|
1162
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
1163
|
+
options?.onPreflightAccepted?.();
|
|
1164
|
+
return {};
|
|
1165
|
+
}),
|
|
1166
|
+
} as unknown as ExtensionAPI;
|
|
1167
|
+
const transport = memoryTransport();
|
|
1168
|
+
const reconciliationStore = createReconciliationStore({
|
|
1169
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1170
|
+
sessionId: transport.sessionId,
|
|
1171
|
+
});
|
|
1172
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1173
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1174
|
+
agentDir: cwd,
|
|
1175
|
+
createTransport: async () => transport,
|
|
1176
|
+
terminalAbortSeams: {
|
|
1177
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1178
|
+
getTerminalTurnEpoch: () => 7,
|
|
1179
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1180
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1181
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1182
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1183
|
+
return { status: "settled", terminalScope: {} };
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1188
|
+
try {
|
|
1189
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1190
|
+
const prompt = (connectionId: string, id: string) =>
|
|
1191
|
+
transport.feed(connectionId, {
|
|
1192
|
+
type: "control_request",
|
|
1193
|
+
id,
|
|
1194
|
+
operation: "turn.prompt",
|
|
1195
|
+
input: { text: id, images: [] },
|
|
1196
|
+
} as SdkFrame);
|
|
1197
|
+
const waitResponse = async (id: string) => {
|
|
1198
|
+
const deadline = Date.now() + 15_000;
|
|
1199
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
1200
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
1201
|
+
await Bun.sleep(20);
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
// A submits and STARTS its run: owner becomes conn-a.
|
|
1205
|
+
prompt("conn-a", "prompt-a");
|
|
1206
|
+
await waitResponse("prompt-a");
|
|
1207
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1208
|
+
// B submits and is ACCEPTED, but only queued (agent_start not fired).
|
|
1209
|
+
prompt("conn-b", "prompt-b");
|
|
1210
|
+
await waitResponse("prompt-b");
|
|
1211
|
+
expect(transport.sent.find(frame => frame.id === "prompt-b")).toMatchObject({
|
|
1212
|
+
ok: true,
|
|
1213
|
+
result: expect.objectContaining({ accepted: true }),
|
|
1214
|
+
});
|
|
1215
|
+
// B's abort must NOT stop A's streaming turn: the owner is still A.
|
|
1216
|
+
transport.feed("conn-b", {
|
|
1217
|
+
type: "control_request",
|
|
1218
|
+
id: "queued-abort",
|
|
1219
|
+
operation: "turn.abort",
|
|
1220
|
+
input: { mode: "terminal" },
|
|
1221
|
+
idempotencyKey: "queued-abort-key",
|
|
1222
|
+
} as SdkFrame);
|
|
1223
|
+
await waitResponse("queued-abort");
|
|
1224
|
+
expect(transport.sent.find(frame => frame.id === "queued-abort")).toMatchObject({
|
|
1225
|
+
ok: true,
|
|
1226
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
1227
|
+
});
|
|
1228
|
+
expect(seamCalls).toHaveLength(0);
|
|
1229
|
+
// B's run now STARTS: ownership transfers to conn-b, and B's own abort
|
|
1230
|
+
// stops it.
|
|
1231
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1232
|
+
transport.feed("conn-b", {
|
|
1233
|
+
type: "control_request",
|
|
1234
|
+
id: "queued-abort-2",
|
|
1235
|
+
operation: "turn.abort",
|
|
1236
|
+
input: { mode: "terminal" },
|
|
1237
|
+
idempotencyKey: "queued-abort-2-key",
|
|
1238
|
+
} as SdkFrame);
|
|
1239
|
+
await waitResponse("queued-abort-2");
|
|
1240
|
+
expect(transport.sent.find(frame => frame.id === "queued-abort-2")).toMatchObject({
|
|
1241
|
+
ok: true,
|
|
1242
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
1243
|
+
});
|
|
1244
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
|
|
1245
|
+
} finally {
|
|
1246
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1247
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test("SDK-only host retires steering-queued submissions so a later agent-initiated turn is not mis-owned", async () => {
|
|
1252
|
+
// While client A's prompt streams, client B's plain prompt is accepted as
|
|
1253
|
+
// queued STEERING and consumed inside the current run — it emits no
|
|
1254
|
+
// agent_start, so its pending entry must be retired. Otherwise the next
|
|
1255
|
+
// agent-initiated monitor/cron turn's agent_start would shift the stale
|
|
1256
|
+
// entry and let B terminal-abort a turn it did not submit (review thread
|
|
1257
|
+
// P1).
|
|
1258
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stale-owner-"));
|
|
1259
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1260
|
+
const api = {
|
|
1261
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1262
|
+
handlers.set(event, handler);
|
|
1263
|
+
},
|
|
1264
|
+
sendUserMessage: (
|
|
1265
|
+
_content: string,
|
|
1266
|
+
options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
|
|
1267
|
+
) =>
|
|
1268
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
1269
|
+
options?.onPreflightAccepted?.();
|
|
1270
|
+
return {};
|
|
1271
|
+
}),
|
|
1272
|
+
} as unknown as ExtensionAPI;
|
|
1273
|
+
const transport = memoryTransport();
|
|
1274
|
+
const reconciliationStore = createReconciliationStore({
|
|
1275
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1276
|
+
sessionId: transport.sessionId,
|
|
1277
|
+
});
|
|
1278
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1279
|
+
let idle = true;
|
|
1280
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1281
|
+
agentDir: cwd,
|
|
1282
|
+
createTransport: async () => transport,
|
|
1283
|
+
terminalAbortSeams: {
|
|
1284
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1285
|
+
getTerminalTurnEpoch: () => 7,
|
|
1286
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1287
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1288
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1289
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1290
|
+
return { status: "settled", terminalScope: {} };
|
|
1291
|
+
},
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
const ctx = {
|
|
1295
|
+
...extensionContext(transport.sessionId, cwd),
|
|
1296
|
+
isIdle: () => idle,
|
|
1297
|
+
} as unknown as ExtensionContext;
|
|
1298
|
+
try {
|
|
1299
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1300
|
+
const prompt = (connectionId: string, id: string) =>
|
|
1301
|
+
transport.feed(connectionId, {
|
|
1302
|
+
type: "control_request",
|
|
1303
|
+
id,
|
|
1304
|
+
operation: "turn.prompt",
|
|
1305
|
+
input: { text: id, images: [] },
|
|
1306
|
+
} as SdkFrame);
|
|
1307
|
+
const waitResponse = async (id: string) => {
|
|
1308
|
+
const deadline = Date.now() + 15_000;
|
|
1309
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
1310
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
1311
|
+
await Bun.sleep(20);
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
// A submits while idle and STARTS its run: owner becomes conn-a.
|
|
1315
|
+
prompt("conn-a", "stale-a");
|
|
1316
|
+
await waitResponse("stale-a");
|
|
1317
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1318
|
+
// A's turn is now streaming: B's plain prompt is queued as steering and
|
|
1319
|
+
// consumed in-run — its pending entry must NOT be created.
|
|
1320
|
+
idle = false;
|
|
1321
|
+
prompt("conn-b", "stale-b");
|
|
1322
|
+
await waitResponse("stale-b");
|
|
1323
|
+
// A later AGENT-INITIATED turn starts: the pending queue is empty, so
|
|
1324
|
+
// the owner stays conn-a (B's stale connection is not associated).
|
|
1325
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1326
|
+
// B's abort must NOT stop the agent-initiated turn.
|
|
1327
|
+
transport.feed("conn-b", {
|
|
1328
|
+
type: "control_request",
|
|
1329
|
+
id: "stale-owner-abort",
|
|
1330
|
+
operation: "turn.abort",
|
|
1331
|
+
input: { mode: "terminal" },
|
|
1332
|
+
idempotencyKey: "stale-owner-abort-key",
|
|
1333
|
+
} as SdkFrame);
|
|
1334
|
+
await waitResponse("stale-owner-abort");
|
|
1335
|
+
expect(transport.sent.find(frame => frame.id === "stale-owner-abort")).toMatchObject({
|
|
1336
|
+
ok: true,
|
|
1337
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
1338
|
+
});
|
|
1339
|
+
expect(seamCalls).toHaveLength(0);
|
|
1340
|
+
} finally {
|
|
1341
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1342
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test("SDK-only host retires a follow-up queued while streaming so a later agent-initiated turn is not mis-owned", async () => {
|
|
1347
|
+
// While client A's prompt streams, client B's follow-up is consumed by the
|
|
1348
|
+
// active loop (agent-loop.ts getFollowUpMessages) with NO new agent_start.
|
|
1349
|
+
// Its pending entry must not survive to a later agent-initiated turn, or
|
|
1350
|
+
// B could terminal-abort an unrelated monitor/cron turn (review thread
|
|
1351
|
+
// P1).
|
|
1352
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stale-followup-"));
|
|
1353
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1354
|
+
const api = {
|
|
1355
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1356
|
+
handlers.set(event, handler);
|
|
1357
|
+
},
|
|
1358
|
+
sendUserMessage: (
|
|
1359
|
+
_content: string,
|
|
1360
|
+
options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
|
|
1361
|
+
) =>
|
|
1362
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
1363
|
+
options?.onPreflightAccepted?.();
|
|
1364
|
+
return {};
|
|
1365
|
+
}),
|
|
1366
|
+
} as unknown as ExtensionAPI;
|
|
1367
|
+
const transport = memoryTransport();
|
|
1368
|
+
const reconciliationStore = createReconciliationStore({
|
|
1369
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1370
|
+
sessionId: transport.sessionId,
|
|
1371
|
+
});
|
|
1372
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1373
|
+
let idle = true;
|
|
1374
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1375
|
+
agentDir: cwd,
|
|
1376
|
+
createTransport: async () => transport,
|
|
1377
|
+
terminalAbortSeams: {
|
|
1378
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1379
|
+
getTerminalTurnEpoch: () => 7,
|
|
1380
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1381
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1382
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1383
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1384
|
+
return { status: "settled", terminalScope: {} };
|
|
1385
|
+
},
|
|
1386
|
+
},
|
|
1387
|
+
});
|
|
1388
|
+
const ctx = {
|
|
1389
|
+
...extensionContext(transport.sessionId, cwd),
|
|
1390
|
+
isIdle: () => idle,
|
|
1391
|
+
} as unknown as ExtensionContext;
|
|
1392
|
+
try {
|
|
1393
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1394
|
+
const waitResponse = async (id: string) => {
|
|
1395
|
+
const deadline = Date.now() + 15_000;
|
|
1396
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
1397
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
1398
|
+
await Bun.sleep(20);
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
// A starts its run while idle: owner becomes conn-a.
|
|
1402
|
+
transport.feed("conn-a", {
|
|
1403
|
+
type: "control_request",
|
|
1404
|
+
id: "fu-a",
|
|
1405
|
+
operation: "turn.prompt",
|
|
1406
|
+
input: { text: "a", images: [] },
|
|
1407
|
+
} as SdkFrame);
|
|
1408
|
+
await waitResponse("fu-a");
|
|
1409
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1410
|
+
// A streams: B's follow-up is consumed in-run -> NO pending entry.
|
|
1411
|
+
idle = false;
|
|
1412
|
+
transport.feed("conn-b", {
|
|
1413
|
+
type: "control_request",
|
|
1414
|
+
id: "fu-b",
|
|
1415
|
+
operation: "turn.follow_up",
|
|
1416
|
+
input: { text: "follow up b" },
|
|
1417
|
+
} as SdkFrame);
|
|
1418
|
+
await waitResponse("fu-b");
|
|
1419
|
+
// A later agent-initiated turn starts: the pending queue is empty, so
|
|
1420
|
+
// B's stale connection is never associated as owner.
|
|
1421
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1422
|
+
transport.feed("conn-b", {
|
|
1423
|
+
type: "control_request",
|
|
1424
|
+
id: "stale-followup-abort",
|
|
1425
|
+
operation: "turn.abort",
|
|
1426
|
+
input: { mode: "terminal" },
|
|
1427
|
+
idempotencyKey: "stale-followup-abort-key",
|
|
1428
|
+
} as SdkFrame);
|
|
1429
|
+
await waitResponse("stale-followup-abort");
|
|
1430
|
+
expect(transport.sent.find(frame => frame.id === "stale-followup-abort")).toMatchObject({
|
|
1431
|
+
ok: true,
|
|
1432
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
1433
|
+
});
|
|
1434
|
+
expect(seamCalls).toHaveLength(0);
|
|
1435
|
+
} finally {
|
|
1436
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1437
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
test("SDK-only host retires an accepted-then-failed submission so a later agent-initiated turn is not mis-owned", async () => {
|
|
1442
|
+
// An idle submission is accepted (pending entry pushed) and then REJECTS
|
|
1443
|
+
// before agent_start (e.g. the busy retry expires). The failed entry must
|
|
1444
|
+
// be retired — a later agent-initiated monitor/cron turn must not inherit
|
|
1445
|
+
// the failed submission's connection as owner (review thread P1).
|
|
1446
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-failed-owner-"));
|
|
1447
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1448
|
+
const api = {
|
|
1449
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1450
|
+
handlers.set(event, handler);
|
|
1451
|
+
},
|
|
1452
|
+
sendUserMessage: (
|
|
1453
|
+
content: string | { text: string }[],
|
|
1454
|
+
options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
|
|
1455
|
+
) => {
|
|
1456
|
+
const commit = Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
1457
|
+
options?.onPreflightAccepted?.();
|
|
1458
|
+
return {};
|
|
1459
|
+
});
|
|
1460
|
+
const text = typeof content === "string" ? content : (content[0]?.text ?? "");
|
|
1461
|
+
// The "fail-b" submission is accepted, then its run REJECTS.
|
|
1462
|
+
if (text === "fail-b")
|
|
1463
|
+
return commit.then(() => {
|
|
1464
|
+
throw new Error("provider failed after acceptance");
|
|
1465
|
+
});
|
|
1466
|
+
return commit;
|
|
1467
|
+
},
|
|
1468
|
+
} as unknown as ExtensionAPI;
|
|
1469
|
+
const transport = memoryTransport();
|
|
1470
|
+
const reconciliationStore = createReconciliationStore({
|
|
1471
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1472
|
+
sessionId: transport.sessionId,
|
|
1473
|
+
});
|
|
1474
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1475
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1476
|
+
agentDir: cwd,
|
|
1477
|
+
createTransport: async () => transport,
|
|
1478
|
+
terminalAbortSeams: {
|
|
1479
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1480
|
+
getTerminalTurnEpoch: () => 7,
|
|
1481
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1482
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1483
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1484
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1485
|
+
return { status: "settled", terminalScope: {} };
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
});
|
|
1489
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1490
|
+
try {
|
|
1491
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1492
|
+
const prompt = (connectionId: string, id: string, text: string) =>
|
|
1493
|
+
transport.feed(connectionId, {
|
|
1494
|
+
type: "control_request",
|
|
1495
|
+
id,
|
|
1496
|
+
operation: "turn.prompt",
|
|
1497
|
+
input: { text, images: [] },
|
|
1498
|
+
} as SdkFrame);
|
|
1499
|
+
const waitResponse = async (id: string) => {
|
|
1500
|
+
const deadline = Date.now() + 15_000;
|
|
1501
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
1502
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
1503
|
+
await Bun.sleep(20);
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
// A submits while idle and STARTS its run: owner becomes conn-a.
|
|
1507
|
+
prompt("conn-a", "ok-a", "ok-a");
|
|
1508
|
+
await waitResponse("ok-a");
|
|
1509
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1510
|
+
// B submits while idle (entry pushed), then its run REJECTS.
|
|
1511
|
+
prompt("conn-b", "fail-b", "fail-b");
|
|
1512
|
+
await waitResponse("fail-b");
|
|
1513
|
+
expect(transport.sent.find(frame => frame.id === "fail-b")).toMatchObject({
|
|
1514
|
+
ok: true,
|
|
1515
|
+
result: expect.objectContaining({ accepted: true }),
|
|
1516
|
+
});
|
|
1517
|
+
// Let the async rejection settle and retire B's pending entry.
|
|
1518
|
+
await Bun.sleep(50);
|
|
1519
|
+
// A later agent-initiated turn starts: the pending queue is empty, so
|
|
1520
|
+
// B's failed submission is never associated as owner.
|
|
1521
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
1522
|
+
transport.feed("conn-b", {
|
|
1523
|
+
type: "control_request",
|
|
1524
|
+
id: "failed-owner-abort",
|
|
1525
|
+
operation: "turn.abort",
|
|
1526
|
+
input: { mode: "terminal" },
|
|
1527
|
+
idempotencyKey: "failed-owner-abort-key",
|
|
1528
|
+
} as SdkFrame);
|
|
1529
|
+
await waitResponse("failed-owner-abort");
|
|
1530
|
+
expect(transport.sent.find(frame => frame.id === "failed-owner-abort")).toMatchObject({
|
|
1531
|
+
ok: true,
|
|
1532
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
1533
|
+
});
|
|
1534
|
+
expect(seamCalls).toHaveLength(0);
|
|
1535
|
+
} finally {
|
|
1536
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1537
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
test("SDK-only host refuses terminal aborts of UNOWNED agent-initiated turns", async () => {
|
|
1542
|
+
// An agent-initiated turn (monitor/cron follow-up) has NO accepting SDK
|
|
1543
|
+
// connection: the active handle exists but the owner is undefined, so
|
|
1544
|
+
// every client must be refused — never authorized by an absent or stale
|
|
1545
|
+
// owner (review thread P1).
|
|
1546
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-unowned-turn-"));
|
|
1547
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1548
|
+
const api = {
|
|
1549
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1550
|
+
handlers.set(event, handler);
|
|
1551
|
+
},
|
|
1552
|
+
} as unknown as ExtensionAPI;
|
|
1553
|
+
const transport = memoryTransport();
|
|
1554
|
+
const reconciliationStore = createReconciliationStore({
|
|
1555
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1556
|
+
sessionId: transport.sessionId,
|
|
1557
|
+
});
|
|
1558
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1559
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1560
|
+
agentDir: cwd,
|
|
1561
|
+
createTransport: async () => transport,
|
|
1562
|
+
terminalAbortSeams: {
|
|
1563
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1564
|
+
// Active turn exists, but NO owner is recorded (no prompt/skill was
|
|
1565
|
+
// accepted by any SDK connection) — the seam getter is absent so the
|
|
1566
|
+
// runtime-tracked owner (undefined) is consulted.
|
|
1567
|
+
getTerminalTurnEpoch: () => 7,
|
|
1568
|
+
getActivePromptHandle: () => "agent-initiated-handle",
|
|
1569
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1570
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1571
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1572
|
+
return { status: "settled", terminalScope: {} };
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
});
|
|
1576
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1577
|
+
try {
|
|
1578
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1579
|
+
transport.feed("client", {
|
|
1580
|
+
type: "control_request",
|
|
1581
|
+
id: "unowned-abort",
|
|
1582
|
+
operation: "turn.abort",
|
|
1583
|
+
input: { mode: "terminal" },
|
|
1584
|
+
idempotencyKey: "unowned-abort-key",
|
|
1585
|
+
} as SdkFrame);
|
|
1586
|
+
const deadline = Date.now() + 15_000;
|
|
1587
|
+
while (!transport.sent.some(frame => frame.id === "unowned-abort" && frame.type === "control_response")) {
|
|
1588
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the unowned abort response");
|
|
1589
|
+
await Bun.sleep(20);
|
|
1590
|
+
}
|
|
1591
|
+
// No owner: the abort is refused without touching the seam.
|
|
1592
|
+
expect(transport.sent.find(frame => frame.id === "unowned-abort")).toMatchObject({
|
|
1593
|
+
ok: true,
|
|
1594
|
+
result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
1595
|
+
});
|
|
1596
|
+
expect(seamCalls).toHaveLength(0);
|
|
1597
|
+
} finally {
|
|
1598
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1599
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
test("SDK-only host replays a no_effect_reserved row as uncertainty, never a fabricated no_active_turn", async () => {
|
|
1604
|
+
// A transitional no-effect reservation (the abort may still transition to
|
|
1605
|
+
// active while the reservation is awaited) must never replay as a
|
|
1606
|
+
// definitive no_active_turn: a duplicate in that window would otherwise
|
|
1607
|
+
// get no_active_turn while the original stops the prompt (review thread
|
|
1608
|
+
// P2).
|
|
1609
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-reserved-"));
|
|
1610
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1611
|
+
const api = {
|
|
1612
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1613
|
+
handlers.set(event, handler);
|
|
1614
|
+
},
|
|
1615
|
+
} as unknown as ExtensionAPI;
|
|
1616
|
+
const transport = memoryTransport();
|
|
1617
|
+
const reconciliationStore = createReconciliationStore({
|
|
1618
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1619
|
+
sessionId: transport.sessionId,
|
|
1620
|
+
});
|
|
1621
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1622
|
+
agentDir: cwd,
|
|
1623
|
+
createTransport: async () => transport,
|
|
1624
|
+
terminalAbortSeams: {
|
|
1625
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1626
|
+
getTerminalTurnEpoch: () => undefined,
|
|
1627
|
+
getActivePromptHandle: () => undefined,
|
|
1628
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1629
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "settled" }),
|
|
1630
|
+
},
|
|
1631
|
+
});
|
|
1632
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1633
|
+
try {
|
|
1634
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1635
|
+
const keyHash = createHash("sha256").update("reserved-key").digest("hex");
|
|
1636
|
+
const inputHash = createHash("sha256")
|
|
1637
|
+
.update(JSON.stringify({ mode: "terminal", scope: "turn" }))
|
|
1638
|
+
.digest("hex");
|
|
1639
|
+
// Seed a mid-flight reserved reservation directly (the abort's own
|
|
1640
|
+
// writeNoEffect produces this disposition while the recheck is
|
|
1641
|
+
// pending).
|
|
1642
|
+
await reconciliationStore.transactTerminalState(state => ({
|
|
1643
|
+
scopes: [
|
|
1644
|
+
{
|
|
1645
|
+
selection: "turn",
|
|
1646
|
+
idempotencyKeyHash: keyHash,
|
|
1647
|
+
idempotencyInputHash: inputHash,
|
|
1648
|
+
turnDisposition: "no_effect_reserved",
|
|
1649
|
+
terminalPublished: false,
|
|
1650
|
+
ownedWorkDisposition: "not_requested",
|
|
1651
|
+
automaticDeliveryDisposition: "enabled",
|
|
1652
|
+
resumeOnOwnedCompletion: true,
|
|
1653
|
+
turnContinuationFence: {
|
|
1654
|
+
state: "retained",
|
|
1655
|
+
abortedAttemptEpoch: 0,
|
|
1656
|
+
blockedContinuationIds: [],
|
|
1657
|
+
predecessorTombstones: [],
|
|
1658
|
+
ownedCompletionPolicy: "enabled",
|
|
1659
|
+
},
|
|
1660
|
+
responseState: "pending",
|
|
1661
|
+
responsePayloadHash: inputHash,
|
|
1662
|
+
acceptedAt: Date.now(),
|
|
1663
|
+
},
|
|
1664
|
+
...state.scopes,
|
|
1665
|
+
],
|
|
1666
|
+
keys: state.keys,
|
|
1667
|
+
}));
|
|
1668
|
+
transport.feed("client", {
|
|
1669
|
+
type: "control_request",
|
|
1670
|
+
id: "reserved-replay",
|
|
1671
|
+
operation: "turn.abort",
|
|
1672
|
+
input: { mode: "terminal" },
|
|
1673
|
+
idempotencyKey: "reserved-key",
|
|
1674
|
+
} as SdkFrame);
|
|
1675
|
+
const deadline = Date.now() + 15_000;
|
|
1676
|
+
while (!transport.sent.some(frame => frame.id === "reserved-replay" && frame.type === "control_response")) {
|
|
1677
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the reserved-row replay");
|
|
1678
|
+
await Bun.sleep(20);
|
|
1679
|
+
}
|
|
1680
|
+
expect(transport.sent.find(frame => frame.id === "reserved-replay")).toMatchObject({
|
|
1681
|
+
ok: true,
|
|
1682
|
+
result: expect.objectContaining({
|
|
1683
|
+
turn: "uncertain",
|
|
1684
|
+
reason: "reservation_in_flight",
|
|
1685
|
+
replay: expect.objectContaining({ responseState: "pending" }),
|
|
1686
|
+
}),
|
|
1687
|
+
});
|
|
1688
|
+
} finally {
|
|
1689
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1690
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
test("SDK-only host does not advance a pending marker's response state for a mismatched replayed payload", async () => {
|
|
1695
|
+
// When >256 concurrent requests evict an in-flight abort from the dispatch
|
|
1696
|
+
// cache, a same-key retry replays the PENDING marker as pending_replay. The
|
|
1697
|
+
// delivery observer must NOT mark the original marker sent for the
|
|
1698
|
+
// retry's uncertainty response: it only advances when the written
|
|
1699
|
+
// response's payload matches the row's stored hash (review thread P2).
|
|
1700
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-pending-state-"));
|
|
1701
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1702
|
+
const api = {
|
|
1703
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1704
|
+
handlers.set(event, handler);
|
|
1705
|
+
},
|
|
1706
|
+
} as unknown as ExtensionAPI;
|
|
1707
|
+
const transport = memoryTransport();
|
|
1708
|
+
const reconciliationStore = createReconciliationStore({
|
|
1709
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1710
|
+
sessionId: transport.sessionId,
|
|
1711
|
+
});
|
|
1712
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1713
|
+
agentDir: cwd,
|
|
1714
|
+
createTransport: async () => transport,
|
|
1715
|
+
terminalAbortSeams: {
|
|
1716
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1717
|
+
getTerminalTurnEpoch: () => undefined,
|
|
1718
|
+
getActivePromptHandle: () => undefined,
|
|
1719
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1720
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "settled" }),
|
|
1721
|
+
},
|
|
1722
|
+
});
|
|
1723
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1724
|
+
try {
|
|
1725
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1726
|
+
const keyHash = createHash("sha256").update("pending-key").digest("hex");
|
|
1727
|
+
const inputHash = createHash("sha256")
|
|
1728
|
+
.update(JSON.stringify({ mode: "terminal", scope: "turn" }))
|
|
1729
|
+
.digest("hex");
|
|
1730
|
+
// Seed the ORIGINAL in-flight marker: pending, with the input-hash
|
|
1731
|
+
// placeholder as its payload hash.
|
|
1732
|
+
await reconciliationStore.transactTerminalState(state => ({
|
|
1733
|
+
scopes: [
|
|
1734
|
+
{
|
|
1735
|
+
selection: "turn",
|
|
1736
|
+
idempotencyKeyHash: keyHash,
|
|
1737
|
+
idempotencyInputHash: inputHash,
|
|
1738
|
+
turnDisposition: "pending",
|
|
1739
|
+
terminalPublished: false,
|
|
1740
|
+
ownedWorkDisposition: "not_requested",
|
|
1741
|
+
automaticDeliveryDisposition: "enabled",
|
|
1742
|
+
resumeOnOwnedCompletion: true,
|
|
1743
|
+
turnContinuationFence: {
|
|
1744
|
+
state: "retained",
|
|
1745
|
+
abortedAttemptEpoch: 0,
|
|
1746
|
+
blockedContinuationIds: [],
|
|
1747
|
+
predecessorTombstones: [],
|
|
1748
|
+
ownedCompletionPolicy: "enabled",
|
|
1749
|
+
},
|
|
1750
|
+
responseState: "pending",
|
|
1751
|
+
responsePayloadHash: inputHash,
|
|
1752
|
+
acceptedAt: Date.now(),
|
|
1753
|
+
},
|
|
1754
|
+
...state.scopes,
|
|
1755
|
+
],
|
|
1756
|
+
keys: state.keys,
|
|
1757
|
+
}));
|
|
1758
|
+
transport.feed("client", {
|
|
1759
|
+
type: "control_request",
|
|
1760
|
+
id: "pending-replay",
|
|
1761
|
+
operation: "turn.abort",
|
|
1762
|
+
input: { mode: "terminal" },
|
|
1763
|
+
idempotencyKey: "pending-key",
|
|
1764
|
+
} as SdkFrame);
|
|
1765
|
+
const deadline = Date.now() + 15_000;
|
|
1766
|
+
while (!transport.sent.some(frame => frame.id === "pending-replay" && frame.type === "control_response")) {
|
|
1767
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the pending-row replay");
|
|
1768
|
+
await Bun.sleep(20);
|
|
1769
|
+
}
|
|
1770
|
+
// The retry replays the pending marker as pending_replay...
|
|
1771
|
+
expect(transport.sent.find(frame => frame.id === "pending-replay")).toMatchObject({
|
|
1772
|
+
ok: true,
|
|
1773
|
+
result: expect.objectContaining({
|
|
1774
|
+
turn: "uncertain",
|
|
1775
|
+
reason: "replay_pending",
|
|
1776
|
+
replay: expect.objectContaining({ responseState: "pending" }),
|
|
1777
|
+
}),
|
|
1778
|
+
});
|
|
1779
|
+
// ...but the ORIGINAL marker's durable state must NOT be advanced by
|
|
1780
|
+
// the retry's mismatched payload.
|
|
1781
|
+
await Bun.sleep(50);
|
|
1782
|
+
expect(reconciliationStore.snapshotTerminalScopes()[0]!.responseState).toBe("pending");
|
|
1783
|
+
} finally {
|
|
1784
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1785
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
test("SDK-only host replays an EVICTED no-effect reservation as no_active_turn, not uncertain", async () => {
|
|
1790
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-evicted-"));
|
|
1791
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1792
|
+
const api = {
|
|
1793
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1794
|
+
handlers.set(event, handler);
|
|
1795
|
+
},
|
|
1796
|
+
} as unknown as ExtensionAPI;
|
|
1797
|
+
const transport = memoryTransport();
|
|
1798
|
+
const admissions = admissionBarrier(9);
|
|
1799
|
+
const reconciliationStore = createReconciliationStore({
|
|
1800
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1801
|
+
sessionId: transport.sessionId,
|
|
1802
|
+
});
|
|
1803
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1804
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1805
|
+
agentDir: cwd,
|
|
1806
|
+
createTransport: async () => transport,
|
|
1807
|
+
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
1808
|
+
terminalAbortSeams: {
|
|
1809
|
+
maxDurableTerminalReservationsForTests: 8,
|
|
1810
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1811
|
+
// No active turn: every idle abort reserves a no-effect row.
|
|
1812
|
+
getTerminalTurnEpoch: () => undefined,
|
|
1813
|
+
getActivePromptHandle: () => undefined,
|
|
1814
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1815
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1816
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1817
|
+
return { status: "settled", terminalScope: {} };
|
|
1818
|
+
},
|
|
1819
|
+
},
|
|
1820
|
+
});
|
|
1821
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1822
|
+
try {
|
|
1823
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1824
|
+
const idleAbort = (id: string, idempotencyKey: string) =>
|
|
1825
|
+
transport.feed("client", {
|
|
1826
|
+
type: "control_request",
|
|
1827
|
+
id,
|
|
1828
|
+
operation: "turn.abort",
|
|
1829
|
+
input: { mode: "terminal" },
|
|
1830
|
+
idempotencyKey,
|
|
1831
|
+
} as SdkFrame);
|
|
1832
|
+
// Fill the completed-scope bound (256) and overflow once so the FIRST
|
|
1833
|
+
// no-effect row is evicted into a tombstone that preserves its
|
|
1834
|
+
// turnDisposition (review thread P2).
|
|
1835
|
+
for (let index = 0; index < 9; index++) idleAbort(`idle-${index}`, `evict-key-${index}`);
|
|
1836
|
+
await admissions.ready;
|
|
1837
|
+
await reconciliationStore.drain?.();
|
|
1838
|
+
expect(seamCalls).toHaveLength(0);
|
|
1839
|
+
// The overflowed reservation now exists only as a tombstone; replaying
|
|
1840
|
+
// its key must return the original no_active_turn/terminal_no_effect
|
|
1841
|
+
// result deterministically, never a fabricated uncertainty.
|
|
1842
|
+
transport.feed("client", {
|
|
1843
|
+
type: "control_request",
|
|
1844
|
+
id: "evicted-replay",
|
|
1845
|
+
operation: "turn.abort",
|
|
1846
|
+
input: { mode: "terminal" },
|
|
1847
|
+
idempotencyKey: "evict-key-0",
|
|
1848
|
+
} as SdkFrame);
|
|
1849
|
+
const deadline = Date.now() + 15_000;
|
|
1850
|
+
while (!transport.sent.some(frame => frame.id === "evicted-replay")) {
|
|
1851
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the evicted-key replay");
|
|
1852
|
+
await Bun.sleep(20);
|
|
1853
|
+
}
|
|
1854
|
+
expect(transport.sent.find(frame => frame.id === "evicted-replay")).toMatchObject({
|
|
1855
|
+
ok: true,
|
|
1856
|
+
result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
1857
|
+
});
|
|
1858
|
+
// A truly fresh key still reports no_active_turn with no seam call.
|
|
1859
|
+
transport.feed("client", {
|
|
1860
|
+
type: "control_request",
|
|
1861
|
+
id: "fresh-idle",
|
|
1862
|
+
operation: "turn.abort",
|
|
1863
|
+
input: { mode: "terminal" },
|
|
1864
|
+
idempotencyKey: "fresh-key",
|
|
1865
|
+
} as SdkFrame);
|
|
1866
|
+
while (!transport.sent.some(frame => frame.id === "fresh-idle")) {
|
|
1867
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the fresh-key idle abort");
|
|
1868
|
+
await Bun.sleep(20);
|
|
1869
|
+
}
|
|
1870
|
+
expect(transport.sent.find(frame => frame.id === "fresh-idle")).toMatchObject({
|
|
1871
|
+
ok: true,
|
|
1872
|
+
result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
1873
|
+
});
|
|
1874
|
+
expect(seamCalls).toHaveLength(0);
|
|
1875
|
+
} finally {
|
|
1876
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1877
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
test("SDK-only host rejects a same-key different-scope race atomically inside the durable transaction", async () => {
|
|
1882
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-race-"));
|
|
1883
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1884
|
+
const api = {
|
|
1885
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1886
|
+
handlers.set(event, handler);
|
|
1887
|
+
},
|
|
1888
|
+
} as unknown as ExtensionAPI;
|
|
1889
|
+
const transport = memoryTransport();
|
|
1890
|
+
const reconciliationStore = createReconciliationStore({
|
|
1891
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1892
|
+
sessionId: transport.sessionId,
|
|
1893
|
+
});
|
|
1894
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1895
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1896
|
+
agentDir: cwd,
|
|
1897
|
+
createTransport: async () => transport,
|
|
1898
|
+
terminalAbortSeams: {
|
|
1899
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1900
|
+
getTerminalTurnEpoch: () => 7,
|
|
1901
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1902
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
1903
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
1904
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
1905
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
1906
|
+
return { status: "settled", terminalScope: {} };
|
|
1907
|
+
},
|
|
1908
|
+
},
|
|
1909
|
+
});
|
|
1910
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
1911
|
+
try {
|
|
1912
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1913
|
+
const race = {
|
|
1914
|
+
type: "control_request",
|
|
1915
|
+
operation: "turn.abort",
|
|
1916
|
+
input: { mode: "terminal" },
|
|
1917
|
+
idempotencyKey: "race-key",
|
|
1918
|
+
} as SdkFrame;
|
|
1919
|
+
// Both requests pass the earlier snapshot check before either durable
|
|
1920
|
+
// row lands; the serialized transaction must reject the second
|
|
1921
|
+
// (different scope) atomically instead of appending a duplicate-key
|
|
1922
|
+
// row that would make later replay of the first ambiguous (review
|
|
1923
|
+
// thread P2).
|
|
1924
|
+
transport.feed("client", { ...race, id: "race-turn" });
|
|
1925
|
+
transport.feed("client", { ...race, id: "race-owned", input: { mode: "terminal", scope: "owned" } });
|
|
1926
|
+
// The winner's stopped response awaits the bounded agent_end
|
|
1927
|
+
// publication observation, so await both responses instead of a fixed
|
|
1928
|
+
// sleep (review thread P2).
|
|
1929
|
+
const raceDeadline = Date.now() + 15_000;
|
|
1930
|
+
while (
|
|
1931
|
+
!transport.sent.some(frame => frame.id === "race-turn" && frame.type === "control_response") ||
|
|
1932
|
+
!transport.sent.some(frame => frame.id === "race-owned" && frame.type === "control_response")
|
|
1933
|
+
) {
|
|
1934
|
+
if (Date.now() > raceDeadline)
|
|
1935
|
+
throw new Error("Timed out waiting for the same-key different-scope race responses");
|
|
1936
|
+
await Bun.sleep(20);
|
|
1937
|
+
}
|
|
1938
|
+
const turnResponse = transport.sent.find(frame => frame.id === "race-turn");
|
|
1939
|
+
const ownedResponse = transport.sent.find(frame => frame.id === "race-owned");
|
|
1940
|
+
expect(turnResponse).toMatchObject({ type: "control_response", ok: true });
|
|
1941
|
+
expect(ownedResponse).toMatchObject({
|
|
1942
|
+
type: "control_response",
|
|
1943
|
+
ok: false,
|
|
1944
|
+
error: expect.objectContaining({ code: "idempotency_conflict" }),
|
|
1945
|
+
});
|
|
1946
|
+
// Only the admitted request reached the session seam; the loser never
|
|
1947
|
+
// touched the run.
|
|
1948
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
|
|
1949
|
+
// Exactly ONE durable row exists for the key: the winner's.
|
|
1950
|
+
expect(reconciliationStore.snapshotTerminalScopes().filter(s => s.idempotencyKeyHash).length).toBe(1);
|
|
1951
|
+
} finally {
|
|
1952
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1953
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
test("SDK-only host cancels exact owned jobs before reporting stopped_owned", async () => {
|
|
1958
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-owned-"));
|
|
1959
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1960
|
+
const api = {
|
|
1961
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1962
|
+
handlers.set(event, handler);
|
|
1963
|
+
},
|
|
1964
|
+
} as unknown as ExtensionAPI;
|
|
1965
|
+
const transport = memoryTransport();
|
|
1966
|
+
const reconciliationStore = createReconciliationStore({
|
|
1967
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
1968
|
+
sessionId: transport.sessionId,
|
|
1969
|
+
});
|
|
1970
|
+
const manager = new AsyncJobManager({ onJobComplete: async () => {} });
|
|
1971
|
+
resetTerminalAbortRegistriesForTests();
|
|
1972
|
+
AsyncJobManager.setInstance(manager);
|
|
1973
|
+
AsyncJobManager.registerForEndpoint("owned-ep", manager);
|
|
1974
|
+
const gate = Promise.withResolvers<string>();
|
|
1975
|
+
let jobId: string | undefined;
|
|
1976
|
+
let registration: TurnRegistrationKey | undefined;
|
|
1977
|
+
try {
|
|
1978
|
+
jobId = manager.register("bash", "owned job", () => gate.promise);
|
|
1979
|
+
const generation = manager.getJob(jobId)?.generation;
|
|
1980
|
+
expect(generation).toBeTypeOf("string");
|
|
1981
|
+
registration = {
|
|
1982
|
+
endpointId: "owned-ep",
|
|
1983
|
+
endpointGeneration: 1,
|
|
1984
|
+
lineageIdHash: "sdk-owned-lineage",
|
|
1985
|
+
promptAttemptEpoch: 7,
|
|
1986
|
+
jobId,
|
|
1987
|
+
jobGeneration: generation as string,
|
|
1988
|
+
};
|
|
1989
|
+
registerOwnedRegistration(registration as never, { isJobTerminal: () => false });
|
|
1990
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
1991
|
+
createSdkSessionRuntimeExtension(api, {
|
|
1992
|
+
agentDir: cwd,
|
|
1993
|
+
createTransport: async () => transport,
|
|
1994
|
+
terminalAbortSeams: {
|
|
1995
|
+
getReconciliationStore: () => reconciliationStore,
|
|
1996
|
+
getTerminalTurnEpoch: () => 7,
|
|
1997
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
1998
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
1999
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
2000
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
2001
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
2002
|
+
return {
|
|
2003
|
+
status: "settled",
|
|
2004
|
+
terminalScope: {
|
|
2005
|
+
scopeId: "scope-owned",
|
|
2006
|
+
abortedAttemptEpoch: 7,
|
|
2007
|
+
lineageIdHash: "sdk-owned-lineage",
|
|
2008
|
+
},
|
|
2009
|
+
};
|
|
2010
|
+
},
|
|
2011
|
+
},
|
|
2012
|
+
});
|
|
2013
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
2014
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
2015
|
+
transport.feed("client", {
|
|
2016
|
+
type: "control_request",
|
|
2017
|
+
id: "owned-abort",
|
|
2018
|
+
operation: "turn.abort",
|
|
2019
|
+
input: { mode: "terminal", scope: "owned" },
|
|
2020
|
+
idempotencyKey: "owned-key",
|
|
2021
|
+
} as SdkFrame);
|
|
2022
|
+
// Let the background job unwind within the 500ms owned-settlement
|
|
2023
|
+
// grace so quiescence is provable.
|
|
2024
|
+
void Bun.sleep(50).then(() => gate.resolve("done"));
|
|
2025
|
+
const ownedDeadline = Date.now() + 15_000;
|
|
2026
|
+
while (!transport.sent.some(frame => frame.id === "owned-abort" && frame.type === "control_response")) {
|
|
2027
|
+
if (Date.now() > ownedDeadline) throw new Error("Timed out waiting for the owned abort response");
|
|
2028
|
+
await Bun.sleep(20);
|
|
2029
|
+
}
|
|
2030
|
+
const response = transport.sent.find(frame => frame.id === "owned-abort");
|
|
2031
|
+
expect(response).toMatchObject({
|
|
2032
|
+
type: "control_response",
|
|
2033
|
+
ok: true,
|
|
2034
|
+
result: expect.objectContaining({ turn: "stopped", ownedWork: "stopped" }),
|
|
2035
|
+
});
|
|
2036
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "owned" }]);
|
|
2037
|
+
// The exact owned job was cancelled by settleOwnedWork before the
|
|
2038
|
+
// stopped disposition was reported.
|
|
2039
|
+
const settledStatus = jobId ? manager.getJob(jobId)?.status : undefined;
|
|
2040
|
+
expect(settledStatus).toBeDefined();
|
|
2041
|
+
expect(["cancelled", "completed", "failed"]).toContain(settledStatus as string);
|
|
2042
|
+
} finally {
|
|
2043
|
+
gate.resolve("done");
|
|
2044
|
+
if (registration) unregisterOwnedRegistration(registration as never);
|
|
2045
|
+
AsyncJobManager.unregisterManager(manager);
|
|
2046
|
+
AsyncJobManager.setInstance(undefined);
|
|
2047
|
+
await manager.dispose({ timeoutMs: 100 }).catch(() => {});
|
|
2048
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
test("SDK-only host bounds completed terminal rows and retains key tombstones", async () => {
|
|
2053
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-bound-"));
|
|
2054
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2055
|
+
const api = {
|
|
2056
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2057
|
+
handlers.set(event, handler);
|
|
2058
|
+
},
|
|
2059
|
+
} as unknown as ExtensionAPI;
|
|
184
2060
|
const transport = memoryTransport();
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
2061
|
+
const admissions = admissionBarrier(12);
|
|
2062
|
+
const reconciliationStore = createReconciliationStore({
|
|
2063
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
2064
|
+
sessionId: transport.sessionId,
|
|
189
2065
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
2066
|
+
createSdkSessionRuntimeExtension(api, {
|
|
2067
|
+
agentDir: cwd,
|
|
2068
|
+
createTransport: async () => transport,
|
|
2069
|
+
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
2070
|
+
terminalAbortSeams: {
|
|
2071
|
+
maxDurableTerminalReservationsForTests: 8,
|
|
2072
|
+
getReconciliationStore: () => reconciliationStore,
|
|
2073
|
+
getTerminalTurnEpoch: () => undefined,
|
|
2074
|
+
getActivePromptHandle: () => undefined,
|
|
2075
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
2076
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "settled" }),
|
|
2077
|
+
},
|
|
197
2078
|
});
|
|
198
|
-
transport.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
2079
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
2080
|
+
try {
|
|
2081
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
2082
|
+
// Idle terminal aborts with distinct keys must not grow the
|
|
2083
|
+
// reconciliation document without limit: completed rows are bounded
|
|
2084
|
+
// and evicted keys become compact tombstones (review thread P2).
|
|
2085
|
+
for (let index = 0; index < 12; index++) {
|
|
2086
|
+
transport.feed("client", {
|
|
2087
|
+
type: "control_request",
|
|
2088
|
+
id: `bound-${index}`,
|
|
2089
|
+
operation: "turn.abort",
|
|
2090
|
+
input: { mode: "terminal" },
|
|
2091
|
+
idempotencyKey: `bound-key-${index}`,
|
|
2092
|
+
} as SdkFrame);
|
|
2093
|
+
}
|
|
2094
|
+
// Yield once so every fire-and-forget frame handler can admit its first
|
|
2095
|
+
// serialized transaction, then drain the exact durable queue. Polling all
|
|
2096
|
+
// responses under the file's five-second deadline made scheduler load part
|
|
2097
|
+
// of the persistence contract.
|
|
2098
|
+
await admissions.ready;
|
|
2099
|
+
await reconciliationStore.drain?.();
|
|
2100
|
+
expect(transport.sent.length).toBeGreaterThanOrEqual(12);
|
|
2101
|
+
expect(reconciliationStore.snapshotTerminalScopes().length).toBeLessThanOrEqual(8);
|
|
2102
|
+
expect(reconciliationStore.snapshotTerminalKeys().length).toBeGreaterThan(0);
|
|
2103
|
+
} finally {
|
|
2104
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
2105
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2106
|
+
}
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
test("SDK-only host bounds distinct-key terminal markers on UNCERTAIN finalization", async () => {
|
|
2110
|
+
// Many concurrent terminal aborts (distinct keys) of one turn that fails
|
|
2111
|
+
// to settle must not leave an arbitrarily large reconciliation document:
|
|
2112
|
+
// the pending->uncertain finalize applies the same 256-row bound and
|
|
2113
|
+
// retains key tombstones (review thread P2).
|
|
2114
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-uncertain-bound-"));
|
|
2115
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2116
|
+
const api = {
|
|
2117
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2118
|
+
handlers.set(event, handler);
|
|
2119
|
+
},
|
|
2120
|
+
} as unknown as ExtensionAPI;
|
|
2121
|
+
const transport = memoryTransport();
|
|
2122
|
+
const admissions = admissionBarrier(12);
|
|
2123
|
+
const reconciliationStore = createReconciliationStore({
|
|
2124
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
2125
|
+
sessionId: transport.sessionId,
|
|
203
2126
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
2127
|
+
createSdkSessionRuntimeExtension(api, {
|
|
2128
|
+
agentDir: cwd,
|
|
2129
|
+
createTransport: async () => transport,
|
|
2130
|
+
onFrameAdmitted: admissions.onFrameAdmitted,
|
|
2131
|
+
terminalAbortSeams: {
|
|
2132
|
+
maxDurableTerminalReservationsForTests: 8,
|
|
2133
|
+
getReconciliationStore: () => reconciliationStore,
|
|
2134
|
+
getTerminalTurnEpoch: () => 7,
|
|
2135
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
2136
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
2137
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
2138
|
+
// The turn never settles: every abort finalizes its pending marker
|
|
2139
|
+
// to UNCERTAIN (worker_unsettled).
|
|
2140
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "unfenced" }),
|
|
2141
|
+
},
|
|
2142
|
+
});
|
|
2143
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
2144
|
+
try {
|
|
2145
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
2146
|
+
for (let index = 0; index < 12; index++) {
|
|
2147
|
+
transport.feed("client", {
|
|
2148
|
+
type: "control_request",
|
|
2149
|
+
id: `uncertain-${index}`,
|
|
2150
|
+
operation: "turn.abort",
|
|
2151
|
+
input: { mode: "terminal" },
|
|
2152
|
+
idempotencyKey: `uncertain-key-${index}`,
|
|
2153
|
+
} as SdkFrame);
|
|
2154
|
+
}
|
|
2155
|
+
await admissions.ready;
|
|
2156
|
+
await reconciliationStore.drain?.();
|
|
2157
|
+
expect(transport.sent.length).toBeGreaterThanOrEqual(12);
|
|
2158
|
+
// The uncertain finalizes evicted the oldest rows into tombstones.
|
|
2159
|
+
expect(reconciliationStore.snapshotTerminalScopes().length).toBeLessThanOrEqual(8);
|
|
2160
|
+
expect(reconciliationStore.snapshotTerminalKeys().length).toBeGreaterThan(0);
|
|
2161
|
+
} finally {
|
|
2162
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
2163
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2164
|
+
}
|
|
215
2165
|
});
|
|
2166
|
+
|
|
216
2167
|
test("native-like and loopback transports share the same SDK contract matrix", async () => {
|
|
217
2168
|
const nativePolicy = createSdkSurfacePolicy({
|
|
218
2169
|
bindings: ["sdkControl", "cycleModel", "getSkillState"],
|
|
@@ -256,12 +2207,12 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
256
2207
|
});
|
|
257
2208
|
test("failed extension stop retains retry state before replacement start", async () => {
|
|
258
2209
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-extension-"));
|
|
259
|
-
const handlers = new Map<string, (event: unknown, ctx:
|
|
2210
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
260
2211
|
const api = {
|
|
261
|
-
on(event: string, handler: (event: unknown, ctx:
|
|
2212
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
262
2213
|
handlers.set(event, handler);
|
|
263
2214
|
},
|
|
264
|
-
} as
|
|
2215
|
+
} as unknown as ExtensionAPI;
|
|
265
2216
|
const transports: Array<{ starts: number; stops: number }> = [];
|
|
266
2217
|
createSdkSessionRuntimeExtension(api, {
|
|
267
2218
|
agentDir: path.join(cwd, ".gjc", "agent"),
|
|
@@ -366,7 +2317,17 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
366
2317
|
},
|
|
367
2318
|
);
|
|
368
2319
|
await handlers.get("session_shutdown")?.({}, context);
|
|
2320
|
+
// DR-1 keeps the unregistered row listed, so the two refusals stay distinct:
|
|
2321
|
+
// a matching generation on a terminal row is terminally gone (no endpoint will
|
|
2322
|
+
// ever be issued again, and close takes its signal fallback), while a rotated
|
|
2323
|
+
// generation is still merely stale and worth re-reading.
|
|
369
2324
|
expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 })).toMatchObject(
|
|
2325
|
+
{
|
|
2326
|
+
ok: false,
|
|
2327
|
+
error: { code: "resource_gone", message: "session endpoint record is gone" },
|
|
2328
|
+
},
|
|
2329
|
+
);
|
|
2330
|
+
expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 2 })).toMatchObject(
|
|
370
2331
|
{
|
|
371
2332
|
ok: false,
|
|
372
2333
|
error: { code: "endpoint_stale", message: "session endpoint is stale" },
|
|
@@ -747,3 +2708,795 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
747
2708
|
}
|
|
748
2709
|
});
|
|
749
2710
|
});
|
|
2711
|
+
|
|
2712
|
+
test("SDK-only host never advances a finalized uncertain row for a mismatched replayed payload", async () => {
|
|
2713
|
+
// Review thread P2: before the payload-hash fix, an uncertain/no-effect row
|
|
2714
|
+
// kept the input-hash placeholder, and the response-state advance trusted
|
|
2715
|
+
// ANY non-pending placeholder — so a same-key retry whose response differed
|
|
2716
|
+
// from the original (e.g. pending_replay delivered late) could mark the
|
|
2717
|
+
// durable row sent. Finalization now stores the EXACT final payload hash,
|
|
2718
|
+
// so a mismatched delivery must leave the row pending.
|
|
2719
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-uncertain-payload-"));
|
|
2720
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2721
|
+
const api = {
|
|
2722
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2723
|
+
handlers.set(event, handler);
|
|
2724
|
+
},
|
|
2725
|
+
} as unknown as ExtensionAPI;
|
|
2726
|
+
const transport = memoryTransport();
|
|
2727
|
+
const reconciliationStore = createReconciliationStore({
|
|
2728
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
2729
|
+
sessionId: transport.sessionId,
|
|
2730
|
+
});
|
|
2731
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
2732
|
+
createSdkSessionRuntimeExtension(api, {
|
|
2733
|
+
agentDir: cwd,
|
|
2734
|
+
createTransport: async () => transport,
|
|
2735
|
+
terminalAbortSeams: {
|
|
2736
|
+
getReconciliationStore: () => reconciliationStore,
|
|
2737
|
+
getTerminalTurnEpoch: () => 7,
|
|
2738
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
2739
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
2740
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
2741
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
2742
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
2743
|
+
return { status: "settled", terminalScope: {} };
|
|
2744
|
+
},
|
|
2745
|
+
},
|
|
2746
|
+
});
|
|
2747
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
2748
|
+
try {
|
|
2749
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
2750
|
+
const keyHash = createHash("sha256").update("uncertain-key").digest("hex");
|
|
2751
|
+
const inputHash = createHash("sha256")
|
|
2752
|
+
.update(JSON.stringify({ mode: "terminal", scope: "turn" }))
|
|
2753
|
+
.digest("hex");
|
|
2754
|
+
// Seed a FINALIZED uncertain row with responseState still pending (the
|
|
2755
|
+
// original response was never delivered). Post-fix finalization stores
|
|
2756
|
+
// the exact payload hash; simulating the pre-fix placeholder state must
|
|
2757
|
+
// not let a mismatched retry delivery advance the row.
|
|
2758
|
+
await reconciliationStore.transactTerminalState(state => ({
|
|
2759
|
+
scopes: [
|
|
2760
|
+
{
|
|
2761
|
+
selection: "turn",
|
|
2762
|
+
idempotencyKeyHash: keyHash,
|
|
2763
|
+
idempotencyInputHash: inputHash,
|
|
2764
|
+
turnDisposition: "uncertain",
|
|
2765
|
+
terminalPublished: false,
|
|
2766
|
+
ownedWorkDisposition: "uncertain",
|
|
2767
|
+
automaticDeliveryDisposition: "none",
|
|
2768
|
+
resumeOnOwnedCompletion: false,
|
|
2769
|
+
turnContinuationFence: {
|
|
2770
|
+
state: "retained",
|
|
2771
|
+
abortedAttemptEpoch: 0,
|
|
2772
|
+
blockedContinuationIds: [],
|
|
2773
|
+
predecessorTombstones: [],
|
|
2774
|
+
ownedCompletionPolicy: "disabled",
|
|
2775
|
+
},
|
|
2776
|
+
responseState: "pending",
|
|
2777
|
+
responsePayloadHash: inputHash,
|
|
2778
|
+
acceptedAt: Date.now(),
|
|
2779
|
+
},
|
|
2780
|
+
...state.scopes,
|
|
2781
|
+
],
|
|
2782
|
+
keys: state.keys,
|
|
2783
|
+
}));
|
|
2784
|
+
transport.feed("client", {
|
|
2785
|
+
type: "control_request",
|
|
2786
|
+
id: "uncertain-replay",
|
|
2787
|
+
operation: "turn.abort",
|
|
2788
|
+
input: { mode: "terminal" },
|
|
2789
|
+
idempotencyKey: "uncertain-key",
|
|
2790
|
+
} as SdkFrame);
|
|
2791
|
+
const deadline = Date.now() + 15_000;
|
|
2792
|
+
while (!transport.sent.some(frame => frame.id === "uncertain-replay" && frame.type === "control_response")) {
|
|
2793
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the uncertain-row replay");
|
|
2794
|
+
await Bun.sleep(20);
|
|
2795
|
+
}
|
|
2796
|
+
// The retry replays the finalized row (its payload differs from the
|
|
2797
|
+
// seeded placeholder), so the delivery must NOT advance the row.
|
|
2798
|
+
await Bun.sleep(50);
|
|
2799
|
+
expect(reconciliationStore.snapshotTerminalScopes()[0]!.responseState).toBe("pending");
|
|
2800
|
+
} finally {
|
|
2801
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
2802
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2803
|
+
}
|
|
2804
|
+
});
|
|
2805
|
+
|
|
2806
|
+
test("SDK-only host FIFO-expires tombstones instead of failing the finalization at the cap", async () => {
|
|
2807
|
+
// Review thread P2: a long-lived session fills the evicted-key tombstone
|
|
2808
|
+
// collection with unique terminal-abort keys. The next finalization must
|
|
2809
|
+
// FIFO-expire the oldest tombstones instead of throwing — the destructive
|
|
2810
|
+
// stop may already have succeeded, and throwing would leave the client
|
|
2811
|
+
// with an error and its durable row pending, with subsequent aborts
|
|
2812
|
+
// repeating the failure.
|
|
2813
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-tombstone-cap-"));
|
|
2814
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2815
|
+
const api = {
|
|
2816
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2817
|
+
handlers.set(event, handler);
|
|
2818
|
+
},
|
|
2819
|
+
} as unknown as ExtensionAPI;
|
|
2820
|
+
const transport = memoryTransport();
|
|
2821
|
+
const reconciliationStore = createReconciliationStore({
|
|
2822
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
2823
|
+
sessionId: transport.sessionId,
|
|
2824
|
+
});
|
|
2825
|
+
createSdkSessionRuntimeExtension(api, {
|
|
2826
|
+
agentDir: cwd,
|
|
2827
|
+
createTransport: async () => transport,
|
|
2828
|
+
terminalAbortSeams: {
|
|
2829
|
+
getReconciliationStore: () => reconciliationStore,
|
|
2830
|
+
getTerminalTurnEpoch: () => 7,
|
|
2831
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
2832
|
+
getActivePromptOwnerConnectionId: () => undefined,
|
|
2833
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
2834
|
+
abortPromptAndWaitWithTerminal: async (_handle, _options) => ({ status: "settled", terminalScope: {} }),
|
|
2835
|
+
},
|
|
2836
|
+
});
|
|
2837
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
2838
|
+
try {
|
|
2839
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
2840
|
+
// Fill the tombstone collection to the 4096 cap.
|
|
2841
|
+
await reconciliationStore.transactTerminalState(state => ({
|
|
2842
|
+
scopes: state.scopes,
|
|
2843
|
+
keys: Array.from({ length: 4096 }, (_, i) => ({
|
|
2844
|
+
keyHash: createHash("sha256").update(`cap-key-${i}`).digest("hex"),
|
|
2845
|
+
inputHash: createHash("sha256").update(`cap-input-${i}`).digest("hex"),
|
|
2846
|
+
turnDisposition: "stopped" as const,
|
|
2847
|
+
ownedWorkDisposition: "left_running" as const,
|
|
2848
|
+
responseState: "pending" as const,
|
|
2849
|
+
responsePayloadHash: createHash("sha256").update("p").digest("hex"),
|
|
2850
|
+
})),
|
|
2851
|
+
}));
|
|
2852
|
+
// The next idle abort finalizes a no-effect reservation: the oldest
|
|
2853
|
+
// tombstone expires instead of the finalization throwing.
|
|
2854
|
+
transport.feed("client", {
|
|
2855
|
+
type: "control_request",
|
|
2856
|
+
id: "cap-abort",
|
|
2857
|
+
operation: "turn.abort",
|
|
2858
|
+
input: { mode: "terminal" },
|
|
2859
|
+
idempotencyKey: "cap-abort-key",
|
|
2860
|
+
} as SdkFrame);
|
|
2861
|
+
const deadline = Date.now() + 15_000;
|
|
2862
|
+
while (!transport.sent.some(frame => frame.id === "cap-abort" && frame.type === "control_response")) {
|
|
2863
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the cap abort response");
|
|
2864
|
+
await Bun.sleep(20);
|
|
2865
|
+
}
|
|
2866
|
+
expect(transport.sent.find(frame => frame.id === "cap-abort")).toMatchObject({
|
|
2867
|
+
ok: true,
|
|
2868
|
+
result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
2869
|
+
});
|
|
2870
|
+
// The collection stays bounded at the cap.
|
|
2871
|
+
expect(reconciliationStore.snapshotTerminalKeys()).toHaveLength(4096);
|
|
2872
|
+
} finally {
|
|
2873
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
2874
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2875
|
+
}
|
|
2876
|
+
});
|
|
2877
|
+
|
|
2878
|
+
test("SDK-only host cancels only the aborting requester's preflight while another connection is admitted", async () => {
|
|
2879
|
+
// Review thread P1: a queued requester's terminal abort rejects its own
|
|
2880
|
+
// wrapper callback but must NOT invoke the session-wide preflight abort
|
|
2881
|
+
// while another connection has an active pending admission — the seam
|
|
2882
|
+
// cancels the session's single controller, which would kill the other
|
|
2883
|
+
// connection's preflight while the aborting requester's queued admission
|
|
2884
|
+
// may still start on the newly reset controller.
|
|
2885
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-preflight-scope-"));
|
|
2886
|
+
let seamCancels = 0;
|
|
2887
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2888
|
+
const api = {
|
|
2889
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2890
|
+
handlers.set(event, handler);
|
|
2891
|
+
},
|
|
2892
|
+
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
2893
|
+
await options?.onPreflightAcceptCommit?.();
|
|
2894
|
+
},
|
|
2895
|
+
} as unknown as ExtensionAPI;
|
|
2896
|
+
const transport = memoryTransport();
|
|
2897
|
+
const reconciliationStore = createReconciliationStore({
|
|
2898
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
2899
|
+
sessionId: transport.sessionId,
|
|
2900
|
+
});
|
|
2901
|
+
createSdkSessionRuntimeExtension(api, {
|
|
2902
|
+
agentDir: cwd,
|
|
2903
|
+
createTransport: async () => transport,
|
|
2904
|
+
terminalAbortSeams: {
|
|
2905
|
+
getReconciliationStore: () => reconciliationStore,
|
|
2906
|
+
getTerminalTurnEpoch: () => 7,
|
|
2907
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
2908
|
+
getActivePromptOwnerConnectionId: () => undefined,
|
|
2909
|
+
cancelPendingPreflightForTerminalAbort: () => {
|
|
2910
|
+
seamCancels++;
|
|
2911
|
+
},
|
|
2912
|
+
abortPromptAndWaitWithTerminal: async (_handle, _options) => ({ status: "settled", terminalScope: {} }),
|
|
2913
|
+
},
|
|
2914
|
+
});
|
|
2915
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
2916
|
+
try {
|
|
2917
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
2918
|
+
const waitResponse = async (id: string) => {
|
|
2919
|
+
const deadline = Date.now() + 15_000;
|
|
2920
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
2921
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
2922
|
+
await Bun.sleep(20);
|
|
2923
|
+
}
|
|
2924
|
+
};
|
|
2925
|
+
// Two connections admit prompts while idle; both preflights are pending.
|
|
2926
|
+
transport.feed("conn-a", {
|
|
2927
|
+
type: "control_request",
|
|
2928
|
+
id: "preflight-a",
|
|
2929
|
+
operation: "turn.prompt",
|
|
2930
|
+
input: { text: "a" },
|
|
2931
|
+
} as SdkFrame);
|
|
2932
|
+
await waitResponse("preflight-a");
|
|
2933
|
+
transport.feed("conn-b", {
|
|
2934
|
+
type: "control_request",
|
|
2935
|
+
id: "preflight-b",
|
|
2936
|
+
operation: "turn.prompt",
|
|
2937
|
+
input: { text: "b" },
|
|
2938
|
+
} as SdkFrame);
|
|
2939
|
+
await waitResponse("preflight-b");
|
|
2940
|
+
// Conn B terminal-aborts before its run starts: only B's wrapper
|
|
2941
|
+
// preflight may be cancelled; A's active preflight must survive.
|
|
2942
|
+
transport.feed("conn-b", {
|
|
2943
|
+
type: "control_request",
|
|
2944
|
+
id: "preflight-abort-b",
|
|
2945
|
+
operation: "turn.abort",
|
|
2946
|
+
input: { mode: "terminal" },
|
|
2947
|
+
idempotencyKey: "preflight-abort-b-key",
|
|
2948
|
+
} as SdkFrame);
|
|
2949
|
+
await waitResponse("preflight-abort-b");
|
|
2950
|
+
// The session-wide seam was never invoked while another connection's
|
|
2951
|
+
// preflight was pending — only the aborting requester's wrapper
|
|
2952
|
+
// callback was rejected.
|
|
2953
|
+
expect(seamCancels).toBe(0);
|
|
2954
|
+
} finally {
|
|
2955
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
2956
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2957
|
+
}
|
|
2958
|
+
});
|
|
2959
|
+
|
|
2960
|
+
test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips during the accept window", async () => {
|
|
2961
|
+
// Review thread P1: the production AgentSession begins its in-flight
|
|
2962
|
+
// bookkeeping BEFORE the preflight acceptance callback, so re-reading
|
|
2963
|
+
// isIdle() inside accept() would observe the session as already streaming
|
|
2964
|
+
// and record no pending owner — the submitting connection could then never
|
|
2965
|
+
// terminal-abort its own prompt (the abort would report no_active_turn and
|
|
2966
|
+
// ACP would treat the cancellation as unacknowledged). The startsOwnTurn
|
|
2967
|
+
// decision must come from the PRE-DISPATCH idle snapshot.
|
|
2968
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-startsown-snapshot-"));
|
|
2969
|
+
let idle = true;
|
|
2970
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
2971
|
+
const api = {
|
|
2972
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
2973
|
+
handlers.set(event, handler);
|
|
2974
|
+
},
|
|
2975
|
+
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
2976
|
+
await options?.onPreflightAcceptCommit?.();
|
|
2977
|
+
// The session's in-flight bookkeeping begins during the accept
|
|
2978
|
+
// window: a re-read of isIdle() now reports streaming.
|
|
2979
|
+
idle = false;
|
|
2980
|
+
},
|
|
2981
|
+
} as unknown as ExtensionAPI;
|
|
2982
|
+
const transport = memoryTransport();
|
|
2983
|
+
const reconciliationStore = createReconciliationStore({
|
|
2984
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
2985
|
+
sessionId: transport.sessionId,
|
|
2986
|
+
});
|
|
2987
|
+
createSdkSessionRuntimeExtension(api, {
|
|
2988
|
+
agentDir: cwd,
|
|
2989
|
+
createTransport: async () => transport,
|
|
2990
|
+
terminalAbortSeams: {
|
|
2991
|
+
getReconciliationStore: () => reconciliationStore,
|
|
2992
|
+
getTerminalTurnEpoch: () => 7,
|
|
2993
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
2994
|
+
getActivePromptOwnerConnectionId: () => undefined,
|
|
2995
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
2996
|
+
abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
|
|
2997
|
+
},
|
|
2998
|
+
});
|
|
2999
|
+
const ctx = { ...extensionContext(transport.sessionId, cwd), isIdle: () => idle } as ExtensionContext;
|
|
3000
|
+
try {
|
|
3001
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
3002
|
+
transport.feed("client", {
|
|
3003
|
+
type: "control_request",
|
|
3004
|
+
id: "idle-owner-prompt",
|
|
3005
|
+
operation: "turn.prompt",
|
|
3006
|
+
input: { text: "hello" },
|
|
3007
|
+
} as SdkFrame);
|
|
3008
|
+
const deadline = Date.now() + 15_000;
|
|
3009
|
+
while (!transport.sent.some(frame => frame.id === "idle-owner-prompt" && frame.type === "control_response")) {
|
|
3010
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the idle prompt acceptance");
|
|
3011
|
+
await Bun.sleep(20);
|
|
3012
|
+
}
|
|
3013
|
+
expect(transport.sent.find(frame => frame.id === "idle-owner-prompt")).toMatchObject({ ok: true });
|
|
3014
|
+
// The run starts: the dispatch-time idle snapshot recorded the pending
|
|
3015
|
+
// owner for the submitting connection.
|
|
3016
|
+
await handlers.get("agent_start")?.({}, ctx);
|
|
3017
|
+
transport.feed("client", {
|
|
3018
|
+
type: "control_request",
|
|
3019
|
+
id: "idle-owner-abort",
|
|
3020
|
+
operation: "turn.abort",
|
|
3021
|
+
input: { mode: "terminal" },
|
|
3022
|
+
idempotencyKey: "idle-owner-abort-key",
|
|
3023
|
+
} as SdkFrame);
|
|
3024
|
+
const abortDeadline = Date.now() + 15_000;
|
|
3025
|
+
while (!transport.sent.some(frame => frame.id === "idle-owner-abort" && frame.type === "control_response")) {
|
|
3026
|
+
if (Date.now() > abortDeadline) throw new Error("Timed out waiting for the idle-prompt abort");
|
|
3027
|
+
await Bun.sleep(20);
|
|
3028
|
+
}
|
|
3029
|
+
expect(transport.sent.find(frame => frame.id === "idle-owner-abort")).toMatchObject({
|
|
3030
|
+
ok: true,
|
|
3031
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
3032
|
+
});
|
|
3033
|
+
} finally {
|
|
3034
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3035
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3036
|
+
}
|
|
3037
|
+
});
|
|
3038
|
+
|
|
3039
|
+
test("SDK-only host advances a finalized stopped row when the retry replay matches the stored replay hash", async () => {
|
|
3040
|
+
// Review thread P2: a row finalized with responseState still pending (the
|
|
3041
|
+
// process exited before the original response was written) stores the
|
|
3042
|
+
// ORIGINAL payload hash; a same-key retry delivers the replay-shaped
|
|
3043
|
+
// payload (replay envelope appended). The finalization now also stores the
|
|
3044
|
+
// replay-shaped hash, so the written retry response advances the row from
|
|
3045
|
+
// pending to sent instead of leaving it durably pending forever.
|
|
3046
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stopped-replay-advance-"));
|
|
3047
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
3048
|
+
const api = {
|
|
3049
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
3050
|
+
handlers.set(event, handler);
|
|
3051
|
+
},
|
|
3052
|
+
} as unknown as ExtensionAPI;
|
|
3053
|
+
const transport = memoryTransport();
|
|
3054
|
+
const reconciliationStore = createReconciliationStore({
|
|
3055
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
3056
|
+
sessionId: transport.sessionId,
|
|
3057
|
+
});
|
|
3058
|
+
let captureCalls = 0;
|
|
3059
|
+
let discardCalls = 0;
|
|
3060
|
+
createSdkSessionRuntimeExtension(api, {
|
|
3061
|
+
agentDir: cwd,
|
|
3062
|
+
createTransport: async () => transport,
|
|
3063
|
+
terminalAbortSeams: {
|
|
3064
|
+
getReconciliationStore: () => reconciliationStore,
|
|
3065
|
+
getTerminalTurnEpoch: () => 7,
|
|
3066
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
3067
|
+
getActivePromptOwnerConnectionId: () => "client",
|
|
3068
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
3069
|
+
captureTerminalAbortSteeringSnapshot: () => {
|
|
3070
|
+
captureCalls += 1;
|
|
3071
|
+
return captureCalls;
|
|
3072
|
+
},
|
|
3073
|
+
discardTerminalAbortSteeringSnapshot: () => {
|
|
3074
|
+
discardCalls += 1;
|
|
3075
|
+
},
|
|
3076
|
+
abortPromptAndWaitWithTerminal: async (_handle, _options) => {
|
|
3077
|
+
return { status: "settled", terminalScope: {} };
|
|
3078
|
+
},
|
|
3079
|
+
},
|
|
3080
|
+
});
|
|
3081
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
3082
|
+
try {
|
|
3083
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
3084
|
+
const keyHash = createHash("sha256").update("stopped-replay-key").digest("hex");
|
|
3085
|
+
const inputHash = createHash("sha256")
|
|
3086
|
+
.update(JSON.stringify({ mode: "terminal", scope: "turn" }))
|
|
3087
|
+
.digest("hex");
|
|
3088
|
+
const result = {
|
|
3089
|
+
ok: true,
|
|
3090
|
+
selection: "turn",
|
|
3091
|
+
turn: "stopped",
|
|
3092
|
+
ownedWork: "left_running",
|
|
3093
|
+
automaticDelivery: "enabled",
|
|
3094
|
+
resumeOnOwnedCompletion: true,
|
|
3095
|
+
};
|
|
3096
|
+
const payloadHash = createHash("sha256").update(JSON.stringify(result)).digest("hex");
|
|
3097
|
+
const replayResult = {
|
|
3098
|
+
...result,
|
|
3099
|
+
// The replay envelope carries the POST-CAS publication flag the
|
|
3100
|
+
// stopped-row CAS observed (agent_end published); the seeded
|
|
3101
|
+
// replay-shaped hash must match that exact envelope (review
|
|
3102
|
+
// thread P2).
|
|
3103
|
+
replay: { responseState: "pending", responsePayloadHash: payloadHash, terminalPublished: true },
|
|
3104
|
+
};
|
|
3105
|
+
const replayPayloadHash = createHash("sha256").update(JSON.stringify(replayResult)).digest("hex");
|
|
3106
|
+
// Seed the POST-finalization durable state: the original stopped result
|
|
3107
|
+
// hash plus the replay-shaped hash a same-key retry delivers.
|
|
3108
|
+
await reconciliationStore.transactTerminalState(state => ({
|
|
3109
|
+
scopes: [
|
|
3110
|
+
{
|
|
3111
|
+
selection: "turn",
|
|
3112
|
+
idempotencyKeyHash: keyHash,
|
|
3113
|
+
idempotencyInputHash: inputHash,
|
|
3114
|
+
turnDisposition: "stopped",
|
|
3115
|
+
terminalPublished: true,
|
|
3116
|
+
ownedWorkDisposition: "left_running",
|
|
3117
|
+
automaticDeliveryDisposition: "enabled",
|
|
3118
|
+
resumeOnOwnedCompletion: true,
|
|
3119
|
+
turnContinuationFence: {
|
|
3120
|
+
state: "retained",
|
|
3121
|
+
abortedAttemptEpoch: 0,
|
|
3122
|
+
blockedContinuationIds: [],
|
|
3123
|
+
predecessorTombstones: [],
|
|
3124
|
+
ownedCompletionPolicy: "disabled",
|
|
3125
|
+
},
|
|
3126
|
+
responseState: "pending",
|
|
3127
|
+
responsePayloadHash: payloadHash,
|
|
3128
|
+
replayPayloadHash,
|
|
3129
|
+
acceptedAt: Date.now(),
|
|
3130
|
+
terminalAt: Date.now(),
|
|
3131
|
+
},
|
|
3132
|
+
...state.scopes,
|
|
3133
|
+
],
|
|
3134
|
+
keys: state.keys,
|
|
3135
|
+
}));
|
|
3136
|
+
transport.feed("client", {
|
|
3137
|
+
type: "control_request",
|
|
3138
|
+
id: "stopped-replay",
|
|
3139
|
+
operation: "turn.abort",
|
|
3140
|
+
input: { mode: "terminal" },
|
|
3141
|
+
idempotencyKey: "stopped-replay-key",
|
|
3142
|
+
} as SdkFrame);
|
|
3143
|
+
const deadline = Date.now() + 15_000;
|
|
3144
|
+
while (!transport.sent.some(frame => frame.id === "stopped-replay" && frame.type === "control_response")) {
|
|
3145
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the stopped-row replay");
|
|
3146
|
+
await Bun.sleep(20);
|
|
3147
|
+
}
|
|
3148
|
+
// The written replay's payload matches the stored replay-shaped hash, so
|
|
3149
|
+
// the delivery observer advances the durable row to sent.
|
|
3150
|
+
const stateDeadline = Date.now() + 15_000;
|
|
3151
|
+
while (reconciliationStore.snapshotTerminalScopes()[0]!.responseState !== "sent") {
|
|
3152
|
+
if (Date.now() > stateDeadline) throw new Error("Timed out waiting for the stopped-row response state");
|
|
3153
|
+
await Bun.sleep(20);
|
|
3154
|
+
}
|
|
3155
|
+
expect(transport.sent.find(frame => frame.id === "stopped-replay")).toMatchObject({
|
|
3156
|
+
ok: true,
|
|
3157
|
+
result: expect.objectContaining({
|
|
3158
|
+
turn: "stopped",
|
|
3159
|
+
replay: expect.objectContaining({ responseState: "pending" }),
|
|
3160
|
+
}),
|
|
3161
|
+
});
|
|
3162
|
+
// The durable replay (dispatch-cache eviction equivalent: the row was
|
|
3163
|
+
// seeded before this runtime admitted anything) captured a snapshot at
|
|
3164
|
+
// admission and then DISCARDED it — the replay path never settles, so
|
|
3165
|
+
// the FIFO holds no stale entry for a later real abort to consume
|
|
3166
|
+
// (review thread P1).
|
|
3167
|
+
expect(captureCalls).toBe(1);
|
|
3168
|
+
expect(discardCalls).toBe(1);
|
|
3169
|
+
} finally {
|
|
3170
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3171
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3172
|
+
}
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
test("SDK-only host does not assign a follow-up requester ownership until the follow-up actually starts", async () => {
|
|
3176
|
+
// Review thread P1: a turn.follow_up accepted while ctx.isIdle() is true
|
|
3177
|
+
// but the follow-up is never promoted (compaction, transcript ending in a
|
|
3178
|
+
// user/tool-result message) must not record the requester in pending — a
|
|
3179
|
+
// later unrelated agent_start would shift the stale entry and let the
|
|
3180
|
+
// follow-up connection terminal-abort a turn it did not submit. Ownership
|
|
3181
|
+
// correlates only when the queued follow-up is actually promoted.
|
|
3182
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-followup-stale-"));
|
|
3183
|
+
const idle = true;
|
|
3184
|
+
let promoted: (() => void) | undefined;
|
|
3185
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
3186
|
+
const api = {
|
|
3187
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
3188
|
+
handlers.set(event, handler);
|
|
3189
|
+
},
|
|
3190
|
+
sendUserMessage: (
|
|
3191
|
+
_content: string,
|
|
3192
|
+
options:
|
|
3193
|
+
| {
|
|
3194
|
+
onPreflightAccepted?: () => void;
|
|
3195
|
+
onPreflightAcceptCommit?: () => void;
|
|
3196
|
+
onQueuedPromoted?: () => void;
|
|
3197
|
+
}
|
|
3198
|
+
| undefined,
|
|
3199
|
+
) =>
|
|
3200
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
3201
|
+
promoted = options?.onQueuedPromoted;
|
|
3202
|
+
options?.onPreflightAccepted?.();
|
|
3203
|
+
return {};
|
|
3204
|
+
}),
|
|
3205
|
+
} as unknown as ExtensionAPI;
|
|
3206
|
+
const transport = memoryTransport();
|
|
3207
|
+
const reconciliationStore = createReconciliationStore({
|
|
3208
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
3209
|
+
sessionId: transport.sessionId,
|
|
3210
|
+
});
|
|
3211
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3212
|
+
createSdkSessionRuntimeExtension(api, {
|
|
3213
|
+
agentDir: cwd,
|
|
3214
|
+
createTransport: async () => transport,
|
|
3215
|
+
terminalAbortSeams: {
|
|
3216
|
+
getReconciliationStore: () => reconciliationStore,
|
|
3217
|
+
getTerminalTurnEpoch: () => 7,
|
|
3218
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
3219
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
3220
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
3221
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
3222
|
+
return { status: "settled", terminalScope: {} };
|
|
3223
|
+
},
|
|
3224
|
+
},
|
|
3225
|
+
});
|
|
3226
|
+
const ctx = {
|
|
3227
|
+
...extensionContext(transport.sessionId, cwd),
|
|
3228
|
+
isIdle: () => idle,
|
|
3229
|
+
} as unknown as ExtensionContext;
|
|
3230
|
+
try {
|
|
3231
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
3232
|
+
const waitResponse = async (id: string) => {
|
|
3233
|
+
const deadline = Date.now() + 15_000;
|
|
3234
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
3235
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
3236
|
+
await Bun.sleep(20);
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
// B submits a follow-up while idle. The follow-up is QUEUED (never starts
|
|
3240
|
+
// inline), and with no promotion (e.g. compaction holds the continuation)
|
|
3241
|
+
// no pending ownership entry may exist — even though isIdle is true.
|
|
3242
|
+
transport.feed("conn-b", {
|
|
3243
|
+
type: "control_request",
|
|
3244
|
+
id: "followup-b",
|
|
3245
|
+
operation: "turn.follow_up",
|
|
3246
|
+
input: { text: "followup-b" },
|
|
3247
|
+
} as SdkFrame);
|
|
3248
|
+
await waitResponse("followup-b");
|
|
3249
|
+
// The promotion hook exists but is NOT fired in this scenario.
|
|
3250
|
+
expect(promoted).toBeDefined();
|
|
3251
|
+
// An unrelated turn starts: the pending queue is empty, so the owner is
|
|
3252
|
+
// NOT conn-b.
|
|
3253
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
3254
|
+
// B's abort must NOT stop the unrelated turn.
|
|
3255
|
+
transport.feed("conn-b", {
|
|
3256
|
+
type: "control_request",
|
|
3257
|
+
id: "followup-abort",
|
|
3258
|
+
operation: "turn.abort",
|
|
3259
|
+
input: { mode: "terminal" },
|
|
3260
|
+
idempotencyKey: "followup-abort-key",
|
|
3261
|
+
} as SdkFrame);
|
|
3262
|
+
await waitResponse("followup-abort");
|
|
3263
|
+
expect(transport.sent.find(frame => frame.id === "followup-abort")).toMatchObject({
|
|
3264
|
+
ok: true,
|
|
3265
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
3266
|
+
});
|
|
3267
|
+
expect(seamCalls).toHaveLength(0);
|
|
3268
|
+
// When the follow-up IS promoted, B owns its run and can abort it.
|
|
3269
|
+
promoted!();
|
|
3270
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
3271
|
+
transport.feed("conn-b", {
|
|
3272
|
+
type: "control_request",
|
|
3273
|
+
id: "followup-abort-2",
|
|
3274
|
+
operation: "turn.abort",
|
|
3275
|
+
input: { mode: "terminal" },
|
|
3276
|
+
idempotencyKey: "followup-abort-key-2",
|
|
3277
|
+
} as SdkFrame);
|
|
3278
|
+
await waitResponse("followup-abort-2");
|
|
3279
|
+
expect(transport.sent.find(frame => frame.id === "followup-abort-2")).toMatchObject({
|
|
3280
|
+
ok: true,
|
|
3281
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
3282
|
+
});
|
|
3283
|
+
expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
|
|
3284
|
+
} finally {
|
|
3285
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3286
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3287
|
+
}
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
test("SDK-only host lets every connection whose follow-up was promoted abort the shared run", async () => {
|
|
3291
|
+
// Review thread P2: two connections submit follow-ups while idle before any
|
|
3292
|
+
// scheduled continuation starts; ONE continuation drains both into one run.
|
|
3293
|
+
// The per-message promotion hooks (fired at actual dequeue) must make BOTH
|
|
3294
|
+
// connections owners of that run, so each can terminal-abort work it
|
|
3295
|
+
// submitted, while a foreign connection still cannot.
|
|
3296
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-multi-followup-"));
|
|
3297
|
+
const promoted: Array<() => void> = [];
|
|
3298
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
3299
|
+
const api = {
|
|
3300
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
3301
|
+
handlers.set(event, handler);
|
|
3302
|
+
},
|
|
3303
|
+
sendUserMessage: (
|
|
3304
|
+
_content: string,
|
|
3305
|
+
options:
|
|
3306
|
+
| {
|
|
3307
|
+
onPreflightAccepted?: () => void;
|
|
3308
|
+
onPreflightAcceptCommit?: () => void;
|
|
3309
|
+
onQueuedPromoted?: () => void;
|
|
3310
|
+
}
|
|
3311
|
+
| undefined,
|
|
3312
|
+
) =>
|
|
3313
|
+
Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
|
|
3314
|
+
if (options?.onQueuedPromoted) promoted.push(options.onQueuedPromoted);
|
|
3315
|
+
options?.onPreflightAccepted?.();
|
|
3316
|
+
return {};
|
|
3317
|
+
}),
|
|
3318
|
+
} as unknown as ExtensionAPI;
|
|
3319
|
+
const transport = memoryTransport();
|
|
3320
|
+
const reconciliationStore = createReconciliationStore({
|
|
3321
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
3322
|
+
sessionId: transport.sessionId,
|
|
3323
|
+
});
|
|
3324
|
+
const seamCalls: Array<{ handle: string; scope: string }> = [];
|
|
3325
|
+
createSdkSessionRuntimeExtension(api, {
|
|
3326
|
+
agentDir: cwd,
|
|
3327
|
+
createTransport: async () => transport,
|
|
3328
|
+
terminalAbortSeams: {
|
|
3329
|
+
getReconciliationStore: () => reconciliationStore,
|
|
3330
|
+
getTerminalTurnEpoch: () => 7,
|
|
3331
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
3332
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
3333
|
+
abortPromptAndWaitWithTerminal: async (handle, options) => {
|
|
3334
|
+
seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
|
|
3335
|
+
return { status: "settled", terminalScope: {} };
|
|
3336
|
+
},
|
|
3337
|
+
},
|
|
3338
|
+
});
|
|
3339
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
3340
|
+
try {
|
|
3341
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
3342
|
+
const waitResponse = async (id: string) => {
|
|
3343
|
+
const deadline = Date.now() + 15_000;
|
|
3344
|
+
while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
|
|
3345
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
|
|
3346
|
+
await Bun.sleep(20);
|
|
3347
|
+
}
|
|
3348
|
+
};
|
|
3349
|
+
const followUp = (connectionId: string, id: string) =>
|
|
3350
|
+
transport.feed(connectionId, {
|
|
3351
|
+
type: "control_request",
|
|
3352
|
+
id,
|
|
3353
|
+
operation: "turn.follow_up",
|
|
3354
|
+
input: { text: id },
|
|
3355
|
+
} as SdkFrame);
|
|
3356
|
+
// A and B submit follow-ups while idle: no pending entries at accept.
|
|
3357
|
+
followUp("conn-a", "fu-a");
|
|
3358
|
+
await waitResponse("fu-a");
|
|
3359
|
+
followUp("conn-b", "fu-b");
|
|
3360
|
+
await waitResponse("fu-b");
|
|
3361
|
+
expect(promoted).toHaveLength(2);
|
|
3362
|
+
// ONE continuation drains both follow-ups into one run: both per-message
|
|
3363
|
+
// hooks fire at dequeue, then the run starts.
|
|
3364
|
+
for (const hook of promoted) hook();
|
|
3365
|
+
await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
|
|
3366
|
+
// Both submitting connections can terminal-abort the shared run.
|
|
3367
|
+
for (const [connectionId, id] of [
|
|
3368
|
+
["conn-a", "multi-abort-a"],
|
|
3369
|
+
["conn-b", "multi-abort-b"],
|
|
3370
|
+
] as const) {
|
|
3371
|
+
transport.feed(connectionId, {
|
|
3372
|
+
type: "control_request",
|
|
3373
|
+
id,
|
|
3374
|
+
operation: "turn.abort",
|
|
3375
|
+
input: { mode: "terminal" },
|
|
3376
|
+
idempotencyKey: `${id}-key`,
|
|
3377
|
+
} as SdkFrame);
|
|
3378
|
+
await waitResponse(id);
|
|
3379
|
+
expect(transport.sent.find(frame => frame.id === id)).toMatchObject({
|
|
3380
|
+
ok: true,
|
|
3381
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
expect(seamCalls).toHaveLength(2);
|
|
3385
|
+
// A foreign connection still cannot stop the run.
|
|
3386
|
+
transport.feed("conn-c", {
|
|
3387
|
+
type: "control_request",
|
|
3388
|
+
id: "multi-abort-c",
|
|
3389
|
+
operation: "turn.abort",
|
|
3390
|
+
input: { mode: "terminal" },
|
|
3391
|
+
idempotencyKey: "multi-abort-c-key",
|
|
3392
|
+
} as SdkFrame);
|
|
3393
|
+
await waitResponse("multi-abort-c");
|
|
3394
|
+
expect(transport.sent.find(frame => frame.id === "multi-abort-c")).toMatchObject({
|
|
3395
|
+
ok: true,
|
|
3396
|
+
result: expect.objectContaining({ turn: "no_active_turn" }),
|
|
3397
|
+
});
|
|
3398
|
+
expect(seamCalls).toHaveLength(2);
|
|
3399
|
+
// Review thread P1: EVERY follow-up batched into the shared run must
|
|
3400
|
+
// reach a terminal record. A promotion that only transitioned the first
|
|
3401
|
+
// invocation left the remaining follow-ups durably accepted — their
|
|
3402
|
+
// result lookups would never complete and a restart would report them
|
|
3403
|
+
// failed even though they ran in the shared turn.
|
|
3404
|
+
await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
|
|
3405
|
+
{
|
|
3406
|
+
const terminalDeadline = Date.now() + 15_000;
|
|
3407
|
+
const promptStatuses = () =>
|
|
3408
|
+
reconciliationStore
|
|
3409
|
+
.snapshot()
|
|
3410
|
+
.filter(record => record.kind === "prompt")
|
|
3411
|
+
.map(record => record.status);
|
|
3412
|
+
while (promptStatuses().some(status => status !== "terminal_ok")) {
|
|
3413
|
+
if (Date.now() > terminalDeadline)
|
|
3414
|
+
throw new Error("Timed out waiting for the batched follow-up records to terminalize");
|
|
3415
|
+
await Bun.sleep(20);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
const batchedTerminals = reconciliationStore
|
|
3419
|
+
.snapshot()
|
|
3420
|
+
.filter(record => record.kind === "prompt" && record.status === "terminal_ok");
|
|
3421
|
+
expect(batchedTerminals).toHaveLength(2);
|
|
3422
|
+
} finally {
|
|
3423
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3424
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3425
|
+
}
|
|
3426
|
+
});
|
|
3427
|
+
|
|
3428
|
+
test("SDK-only host rebinds the steering snapshot when the requester's turn wins the owner race", async () => {
|
|
3429
|
+
// Review thread P1: an abort admitted while another connection owns the
|
|
3430
|
+
// active turn captures its snapshot under that OLD turn; when the durable
|
|
3431
|
+
// no-effect reservation reveals that the ABORTING requester's own prompt
|
|
3432
|
+
// became active, the fall-through terminalizes that turn and must REBIND
|
|
3433
|
+
// the admission snapshot to it — otherwise the settlement rejects the
|
|
3434
|
+
// still-old token and the requester's turn keeps running.
|
|
3435
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-snapshot-rebind-"));
|
|
3436
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
3437
|
+
const api = {
|
|
3438
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
3439
|
+
handlers.set(event, handler);
|
|
3440
|
+
},
|
|
3441
|
+
} as unknown as ExtensionAPI;
|
|
3442
|
+
const transport = memoryTransport();
|
|
3443
|
+
const reconciliationStore = createReconciliationStore({
|
|
3444
|
+
sessionFile: path.join(cwd, "session.json"),
|
|
3445
|
+
sessionId: transport.sessionId,
|
|
3446
|
+
});
|
|
3447
|
+
let ownerReads = 0;
|
|
3448
|
+
let rebindCalls = 0;
|
|
3449
|
+
const settledOptions: Array<{ scope?: string; steeringSnapshotToken?: number }> = [];
|
|
3450
|
+
createSdkSessionRuntimeExtension(api, {
|
|
3451
|
+
agentDir: cwd,
|
|
3452
|
+
createTransport: async () => transport,
|
|
3453
|
+
terminalAbortSeams: {
|
|
3454
|
+
getReconciliationStore: () => reconciliationStore,
|
|
3455
|
+
getTerminalTurnEpoch: () => 7,
|
|
3456
|
+
getActivePromptHandle: () => "exact-run-handle",
|
|
3457
|
+
// First read: another connection owns the turn. The recheck after
|
|
3458
|
+
// the durable reservation: the aborting requester now owns it.
|
|
3459
|
+
getActivePromptOwnerConnectionId: () => (ownerReads++ === 0 ? undefined : "client"),
|
|
3460
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
3461
|
+
captureTerminalAbortSteeringSnapshot: () => 42,
|
|
3462
|
+
rebindTerminalAbortSteeringSnapshot: () => {
|
|
3463
|
+
rebindCalls += 1;
|
|
3464
|
+
},
|
|
3465
|
+
abortPromptAndWaitWithTerminal: async (_handle, options) => {
|
|
3466
|
+
settledOptions.push({
|
|
3467
|
+
scope: options.terminal?.scope,
|
|
3468
|
+
steeringSnapshotToken: options.terminal?.steeringSnapshotToken,
|
|
3469
|
+
});
|
|
3470
|
+
return { status: "settled", terminalScope: {} };
|
|
3471
|
+
},
|
|
3472
|
+
},
|
|
3473
|
+
});
|
|
3474
|
+
const ctx = extensionContext(transport.sessionId, cwd);
|
|
3475
|
+
try {
|
|
3476
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
3477
|
+
transport.feed("client", {
|
|
3478
|
+
type: "control_request",
|
|
3479
|
+
id: "owner-race-abort",
|
|
3480
|
+
operation: "turn.abort",
|
|
3481
|
+
input: { mode: "terminal" },
|
|
3482
|
+
idempotencyKey: "owner-race-key",
|
|
3483
|
+
} as SdkFrame);
|
|
3484
|
+
const deadline = Date.now() + 15_000;
|
|
3485
|
+
while (!transport.sent.some(frame => frame.id === "owner-race-abort" && frame.type === "control_response")) {
|
|
3486
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the owner-race abort response");
|
|
3487
|
+
await Bun.sleep(20);
|
|
3488
|
+
}
|
|
3489
|
+
// The fall-through terminalized the requester's rechecked turn: the
|
|
3490
|
+
// admission snapshot was rebound to it and the settlement received the
|
|
3491
|
+
// token instead of a stale rejection.
|
|
3492
|
+
expect(rebindCalls).toBe(1);
|
|
3493
|
+
expect(settledOptions).toEqual([{ scope: "turn", steeringSnapshotToken: 42 }]);
|
|
3494
|
+
expect(transport.sent.find(frame => frame.id === "owner-race-abort")).toMatchObject({
|
|
3495
|
+
ok: true,
|
|
3496
|
+
result: expect.objectContaining({ turn: "stopped" }),
|
|
3497
|
+
});
|
|
3498
|
+
} finally {
|
|
3499
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3500
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3501
|
+
}
|
|
3502
|
+
});
|