@gajae-code/coding-agent 0.18.12 → 0.19.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 +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
|
@@ -4,10 +4,23 @@ import * as fsPromises from "node:fs/promises";
|
|
|
4
4
|
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { deflateSync } from "node:zlib";
|
|
8
|
+
import {
|
|
9
|
+
Agent,
|
|
10
|
+
type AgentOptions,
|
|
11
|
+
createRunResourceLedger,
|
|
12
|
+
markNonDispatchedToolEvent,
|
|
13
|
+
ThinkingLevel,
|
|
14
|
+
} from "@gajae-code/agent-core";
|
|
8
15
|
import { createAttemptMinter } from "@gajae-code/agent-core/attempt-scope";
|
|
9
16
|
import { CompactionCancelledError } from "@gajae-code/agent-core/compaction";
|
|
10
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type AssistantMessage,
|
|
19
|
+
getBundledModel,
|
|
20
|
+
type ImageContent,
|
|
21
|
+
type Model,
|
|
22
|
+
type UserMessage,
|
|
23
|
+
} from "@gajae-code/ai/core";
|
|
11
24
|
import { createMockModel } from "@gajae-code/ai/providers/mock";
|
|
12
25
|
import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream";
|
|
13
26
|
import { logger } from "@gajae-code/utils";
|
|
@@ -17,7 +30,7 @@ import { ModelRegistry } from "../../config/model-registry";
|
|
|
17
30
|
import { Settings } from "../../config/settings";
|
|
18
31
|
import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
|
|
19
32
|
import { ExtensionRunner } from "../../extensibility/extensions/runner";
|
|
20
|
-
import { AgentSession } from "../../session/agent-session";
|
|
33
|
+
import { AgentSession, type QueuedRunStartFailure, type SendUserMessageOptions } from "../../session/agent-session";
|
|
21
34
|
import { AuthStorage } from "../../session/auth-storage";
|
|
22
35
|
import { SessionManager } from "../../session/session-manager";
|
|
23
36
|
import {
|
|
@@ -38,7 +51,8 @@ import {
|
|
|
38
51
|
} from "../bus/reconciliation-store";
|
|
39
52
|
import { PromptDeadlineManager } from "../prompt-deadline-manager";
|
|
40
53
|
import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
|
|
41
|
-
import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
|
|
54
|
+
import { SESSION_HOST_OBSERVER_CAPABILITY, TOOL_ACTIVITY_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
|
|
55
|
+
import { getPromptImageProcessBudgetSnapshot, PromptImageUploadStore } from "./prompt-image-upload";
|
|
42
56
|
import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
|
|
43
57
|
import {
|
|
44
58
|
type CreateSdkSessionRuntimeOptions,
|
|
@@ -1661,6 +1675,62 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1661
1675
|
expect(transport.sent).toContainEqual({ type: "event", kind: "message_update", payload: {} });
|
|
1662
1676
|
await runtime.stop();
|
|
1663
1677
|
});
|
|
1678
|
+
test("applies authoritative replay capabilities before live delivery when the native callback lags", async () => {
|
|
1679
|
+
const replayResponse = Promise.withResolvers<SdkFrame>();
|
|
1680
|
+
const transport = memoryTransport(frame => {
|
|
1681
|
+
if (frame.type === "event_replay_result" && frame.id === "replay-before-capability-update")
|
|
1682
|
+
replayResponse.resolve(frame);
|
|
1683
|
+
});
|
|
1684
|
+
let negotiateCapabilities: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;
|
|
1685
|
+
const hookedTransport: SessionSdkTransport = {
|
|
1686
|
+
...transport,
|
|
1687
|
+
onNegotiatedCapabilities(handler) {
|
|
1688
|
+
negotiateCapabilities = handler;
|
|
1689
|
+
return () => {
|
|
1690
|
+
if (negotiateCapabilities === handler) negotiateCapabilities = undefined;
|
|
1691
|
+
};
|
|
1692
|
+
},
|
|
1693
|
+
};
|
|
1694
|
+
const runtime = new SessionSdkSessionRuntime({ transport: hookedTransport });
|
|
1695
|
+
await runtime.start();
|
|
1696
|
+
try {
|
|
1697
|
+
if (!negotiateCapabilities) throw new Error("transport capability callback was not registered");
|
|
1698
|
+
const observerCapabilities = [
|
|
1699
|
+
TOOL_ACTIVITY_CAPABILITY,
|
|
1700
|
+
TURN_STREAM_CAPABILITY,
|
|
1701
|
+
SESSION_HOST_OBSERVER_CAPABILITY,
|
|
1702
|
+
];
|
|
1703
|
+
runtime.emitEvent({ kind: "tool_activity", payload: { phase: "started" } });
|
|
1704
|
+
transport.feed("observer", {
|
|
1705
|
+
type: "event_replay",
|
|
1706
|
+
id: "replay-before-capability-update",
|
|
1707
|
+
sinceGeneration: runtime.generation,
|
|
1708
|
+
sinceSeq: 0,
|
|
1709
|
+
capabilities: observerCapabilities,
|
|
1710
|
+
});
|
|
1711
|
+
const replay = await replayResponse.promise;
|
|
1712
|
+
expect(replay.events).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "tool_activity" })]));
|
|
1713
|
+
const observerRequirements = [TURN_STREAM_CAPABILITY, SESSION_HOST_OBSERVER_CAPABILITY];
|
|
1714
|
+
expect(runtime.connectionIdsWithCapabilities(observerRequirements)).toEqual(["observer"]);
|
|
1715
|
+
|
|
1716
|
+
runtime.sendFrameToCapabilities(observerRequirements, {
|
|
1717
|
+
type: "event",
|
|
1718
|
+
kind: "tool_activity",
|
|
1719
|
+
payload: { phase: "updated" },
|
|
1720
|
+
});
|
|
1721
|
+
expect(transport.sent).toContainEqual({
|
|
1722
|
+
type: "event",
|
|
1723
|
+
kind: "tool_activity",
|
|
1724
|
+
payload: { phase: "updated" },
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
// A delayed hello snapshot can omit event_replay additions; capability sets only grow per connection.
|
|
1728
|
+
negotiateCapabilities("observer", [TOOL_ACTIVITY_CAPABILITY]);
|
|
1729
|
+
expect(runtime.connectionIdsWithCapabilities(observerRequirements)).toEqual(["observer"]);
|
|
1730
|
+
} finally {
|
|
1731
|
+
await runtime.stop();
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1664
1734
|
test("ignores late negotiated capabilities after the authoritative connection closes", async () => {
|
|
1665
1735
|
const transport = memoryTransport();
|
|
1666
1736
|
let closeConnection: ((connectionId: string) => void) | undefined;
|
|
@@ -4552,6 +4622,7 @@ interface PreflightHooks {
|
|
|
4552
4622
|
expectedSdkRunToken?: string;
|
|
4553
4623
|
preflightSignal?: AbortSignal;
|
|
4554
4624
|
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
4625
|
+
onQueuedRunStartFailed?: (failure: QueuedRunStartFailure) => void;
|
|
4555
4626
|
}
|
|
4556
4627
|
|
|
4557
4628
|
interface ResponseFrame {
|
|
@@ -4567,6 +4638,59 @@ interface ResponseFrame {
|
|
|
4567
4638
|
};
|
|
4568
4639
|
}
|
|
4569
4640
|
|
|
4641
|
+
const TEST_PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
4642
|
+
|
|
4643
|
+
function testPngCrc32(bytes: Uint8Array): number {
|
|
4644
|
+
let crc = 0xffffffff;
|
|
4645
|
+
for (const byte of bytes) {
|
|
4646
|
+
crc ^= byte;
|
|
4647
|
+
for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
4648
|
+
}
|
|
4649
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
function testPngChunk(type: string, data: Uint8Array): Buffer {
|
|
4653
|
+
const typeBytes = Buffer.from(type, "ascii");
|
|
4654
|
+
const payload = Buffer.from(data);
|
|
4655
|
+
const chunk = Buffer.alloc(12 + payload.length);
|
|
4656
|
+
chunk.writeUInt32BE(payload.length, 0);
|
|
4657
|
+
typeBytes.copy(chunk, 4);
|
|
4658
|
+
payload.copy(chunk, 8);
|
|
4659
|
+
chunk.writeUInt32BE(testPngCrc32(Buffer.concat([typeBytes, payload])), 8 + payload.length);
|
|
4660
|
+
return chunk;
|
|
4661
|
+
}
|
|
4662
|
+
|
|
4663
|
+
/** A valid deterministic 300x300 PNG whose source exceeds the old 256 KiB frame limit. */
|
|
4664
|
+
function makeTestPng(): Buffer {
|
|
4665
|
+
const width = 300;
|
|
4666
|
+
const height = 300;
|
|
4667
|
+
const ihdr = Buffer.alloc(13);
|
|
4668
|
+
ihdr.writeUInt32BE(width, 0);
|
|
4669
|
+
ihdr.writeUInt32BE(height, 4);
|
|
4670
|
+
ihdr[8] = 8;
|
|
4671
|
+
ihdr[9] = 6;
|
|
4672
|
+
const stride = 1 + width * 4;
|
|
4673
|
+
const pixels = Buffer.alloc(stride * height);
|
|
4674
|
+
let state = 0x12345678;
|
|
4675
|
+
for (let y = 0; y < height; y++) {
|
|
4676
|
+
const row = y * stride;
|
|
4677
|
+
for (let x = 0; x < width; x++) {
|
|
4678
|
+
const pixel = row + 1 + x * 4;
|
|
4679
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
4680
|
+
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
|
4681
|
+
pixels[pixel + channel] = state >>> 24;
|
|
4682
|
+
}
|
|
4683
|
+
pixels[pixel + 3] = 255;
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
return Buffer.concat([
|
|
4687
|
+
TEST_PNG_SIGNATURE,
|
|
4688
|
+
testPngChunk("IHDR", ihdr),
|
|
4689
|
+
testPngChunk("IDAT", deflateSync(pixels, { level: 0 })),
|
|
4690
|
+
testPngChunk("IEND", Buffer.alloc(0)),
|
|
4691
|
+
]);
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4570
4694
|
interface InvocationHarness {
|
|
4571
4695
|
control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
4572
4696
|
controlAs(
|
|
@@ -4583,6 +4707,8 @@ interface InvocationHarness {
|
|
|
4583
4707
|
sendToSession(sessionId: string, frame: Record<string, unknown>): void;
|
|
4584
4708
|
sent(sessionId: string): SdkFrame[];
|
|
4585
4709
|
readonly broadcasts: SdkFrame[];
|
|
4710
|
+
connect(connectionId: string): void;
|
|
4711
|
+
disconnect(connectionId: string): void;
|
|
4586
4712
|
stop(): Promise<void>;
|
|
4587
4713
|
}
|
|
4588
4714
|
|
|
@@ -4599,6 +4725,8 @@ async function invocationHarness(
|
|
|
4599
4725
|
abort?: () => void;
|
|
4600
4726
|
isIdle?: () => boolean;
|
|
4601
4727
|
settings?: Settings;
|
|
4728
|
+
/** Install the authoritative test transport liveness and disconnect callbacks. */
|
|
4729
|
+
connectionLiveness?: boolean;
|
|
4602
4730
|
/** Throw to inject a durable-write failure for matching transitions. */
|
|
4603
4731
|
persistInterceptor?: (transition: { type: string }) => void;
|
|
4604
4732
|
/** Hold one matching durable transition until the test releases it. */
|
|
@@ -4614,6 +4742,7 @@ async function invocationHarness(
|
|
|
4614
4742
|
type: string | undefined;
|
|
4615
4743
|
commandId?: string;
|
|
4616
4744
|
turnId?: string;
|
|
4745
|
+
terminalAt?: number;
|
|
4617
4746
|
outcome: "rejected" | "committed";
|
|
4618
4747
|
}) => void;
|
|
4619
4748
|
branch?: unknown[];
|
|
@@ -4628,6 +4757,8 @@ async function invocationHarness(
|
|
|
4628
4757
|
const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
|
|
4629
4758
|
const broadcasts: SdkFrame[] = [];
|
|
4630
4759
|
let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
|
|
4760
|
+
const openConnections = new Set<string>();
|
|
4761
|
+
let connectionCloseHandler: ((connectionId: string) => void) | undefined;
|
|
4631
4762
|
const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
|
|
4632
4763
|
const sentFrames = new Map<string, SdkFrame[]>();
|
|
4633
4764
|
let nextId = 0;
|
|
@@ -4651,13 +4782,16 @@ async function invocationHarness(
|
|
|
4651
4782
|
createTestRuntimeExtension(api, {
|
|
4652
4783
|
agentDir: cwd,
|
|
4653
4784
|
...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
|
|
4654
|
-
...(interceptorStore || hooks.terminalAbortSeams || hooks.onPersistenceInstrumentation
|
|
4785
|
+
...(interceptorStore || hooks.terminalAbortSeams || hooks.onPersistenceInstrumentation || hooks.connectionLiveness
|
|
4655
4786
|
? {
|
|
4656
4787
|
terminalAbortSeams: {
|
|
4657
4788
|
getTerminalTurnEpoch: () => undefined,
|
|
4658
|
-
getActivePromptHandle: () => undefined,
|
|
4789
|
+
getActivePromptHandle: () => (hooks.connectionLiveness ? "test-image-run" : undefined),
|
|
4659
4790
|
cancelPendingPreflightForTerminalAbort: () => {},
|
|
4660
4791
|
abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
|
|
4792
|
+
...(hooks.connectionLiveness
|
|
4793
|
+
? { waitForRunSettlement: async () => ({ status: "settled" as const }) }
|
|
4794
|
+
: {}),
|
|
4661
4795
|
...(interceptorStore ? { getReconciliationStore: () => interceptorStore } : {}),
|
|
4662
4796
|
...(hooks.onPersistenceInstrumentation
|
|
4663
4797
|
? { onPersistenceInstrumentationForTests: hooks.onPersistenceInstrumentation }
|
|
@@ -4685,6 +4819,17 @@ async function invocationHarness(
|
|
|
4685
4819
|
if (deliveries.get(id) === handler) deliveries.delete(id);
|
|
4686
4820
|
};
|
|
4687
4821
|
},
|
|
4822
|
+
...(hooks.connectionLiveness
|
|
4823
|
+
? {
|
|
4824
|
+
isConnectionOpen: (connectionId: string) => openConnections.has(connectionId),
|
|
4825
|
+
onConnectionClose(handler: (connectionId: string) => void) {
|
|
4826
|
+
connectionCloseHandler = handler;
|
|
4827
|
+
return () => {
|
|
4828
|
+
if (connectionCloseHandler === handler) connectionCloseHandler = undefined;
|
|
4829
|
+
};
|
|
4830
|
+
},
|
|
4831
|
+
}
|
|
4832
|
+
: {}),
|
|
4688
4833
|
sendFrame(_connectionId, frame) {
|
|
4689
4834
|
const response = frame as ResponseFrame;
|
|
4690
4835
|
const frames = sentFrames.get(id) ?? [];
|
|
@@ -4739,6 +4884,13 @@ async function invocationHarness(
|
|
|
4739
4884
|
),
|
|
4740
4885
|
query: (name, input) => request({ type: "query_request", query: name, input }),
|
|
4741
4886
|
broadcasts,
|
|
4887
|
+
connect: connectionId => {
|
|
4888
|
+
openConnections.add(connectionId);
|
|
4889
|
+
},
|
|
4890
|
+
disconnect: connectionId => {
|
|
4891
|
+
openConnections.delete(connectionId);
|
|
4892
|
+
connectionCloseHandler?.(connectionId);
|
|
4893
|
+
},
|
|
4742
4894
|
emit: async (event, payload) => {
|
|
4743
4895
|
await handlers.get(event)?.(payload ?? {}, ctx);
|
|
4744
4896
|
},
|
|
@@ -4786,6 +4938,1288 @@ async function invocationHarness(
|
|
|
4786
4938
|
};
|
|
4787
4939
|
}
|
|
4788
4940
|
|
|
4941
|
+
async function stageRuntimeImage(harness: InvocationHarness, bytes: Buffer, connectionId = "client"): Promise<string> {
|
|
4942
|
+
const begun = await harness.controlAs(connectionId, "turn.image.begin", {
|
|
4943
|
+
mimeType: "image/png",
|
|
4944
|
+
byteLength: bytes.length,
|
|
4945
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
4946
|
+
});
|
|
4947
|
+
expect(begun).toMatchObject({ ok: true });
|
|
4948
|
+
const id = (begun.result as { id?: unknown } | undefined)?.id;
|
|
4949
|
+
if (typeof id !== "string") throw new Error("SDK runtime did not return a staged image ID.");
|
|
4950
|
+
let sequence = 0;
|
|
4951
|
+
for (let offset = 0; offset < bytes.length; offset += 96 * 1024) {
|
|
4952
|
+
const data = bytes.subarray(offset, offset + 96 * 1024).toString("base64");
|
|
4953
|
+
expect(Buffer.byteLength(JSON.stringify({ id, sequence, data }))).toBeLessThan(256 * 1024);
|
|
4954
|
+
expect(await harness.controlAs(connectionId, "turn.image.append", { id, sequence, data })).toMatchObject({
|
|
4955
|
+
ok: true,
|
|
4956
|
+
});
|
|
4957
|
+
sequence++;
|
|
4958
|
+
}
|
|
4959
|
+
expect(await harness.controlAs(connectionId, "turn.image.finish", { id })).toMatchObject({ ok: true });
|
|
4960
|
+
return id;
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
test.each([
|
|
4964
|
+
"liveness",
|
|
4965
|
+
"settlement",
|
|
4966
|
+
] as const)("SDK-only custom transports without authoritative image ownership omit image controls (%s)", async missing => {
|
|
4967
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-no-image-transport-"));
|
|
4968
|
+
let harness: InvocationHarness | undefined;
|
|
4969
|
+
try {
|
|
4970
|
+
harness = await invocationHarness("sdk-no-image-transport", cwd, {
|
|
4971
|
+
connectionLiveness: missing !== "liveness",
|
|
4972
|
+
terminalAbortSeams: {
|
|
4973
|
+
waitForRunSettlement: missing === "settlement" ? undefined : async () => ({ status: "settled" }),
|
|
4974
|
+
},
|
|
4975
|
+
});
|
|
4976
|
+
harness.connect("client");
|
|
4977
|
+
const capabilities = await harness.query("runtime.capabilities", {});
|
|
4978
|
+
const operations = (capabilities as unknown as { page?: { items?: Array<{ operations?: string[] }> } }).page
|
|
4979
|
+
?.items?.[0]?.operations;
|
|
4980
|
+
expect(operations?.some(operation => operation.startsWith("turn.image."))).toBe(false);
|
|
4981
|
+
const response = await harness.control("turn.image.begin", {
|
|
4982
|
+
mimeType: "image/png",
|
|
4983
|
+
byteLength: 1,
|
|
4984
|
+
sha256: "0".repeat(64),
|
|
4985
|
+
});
|
|
4986
|
+
expect(response).toMatchObject({ ok: false, error: { code: "operation_not_session_owned" } });
|
|
4987
|
+
} finally {
|
|
4988
|
+
await harness?.stop();
|
|
4989
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4990
|
+
}
|
|
4991
|
+
});
|
|
4992
|
+
|
|
4993
|
+
test("SDK-only private staged images are connection-owned, one-shot, and reach the provider losslessly", async () => {
|
|
4994
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-provider-staged-image-"));
|
|
4995
|
+
const bytes = makeTestPng();
|
|
4996
|
+
expect(bytes.length).toBeGreaterThan(256 * 1024);
|
|
4997
|
+
const releaseCalls: string[] = [];
|
|
4998
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
4999
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5000
|
+
this: PromptImageUploadStore,
|
|
5001
|
+
owner,
|
|
5002
|
+
ids,
|
|
5003
|
+
) {
|
|
5004
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5005
|
+
return {
|
|
5006
|
+
images: reservation.images,
|
|
5007
|
+
release: () => {
|
|
5008
|
+
releaseCalls.push("released");
|
|
5009
|
+
reservation.release();
|
|
5010
|
+
},
|
|
5011
|
+
};
|
|
5012
|
+
});
|
|
5013
|
+
let harness: InvocationHarness | undefined;
|
|
5014
|
+
let session: AgentSession | undefined;
|
|
5015
|
+
let authStorage: AuthStorage | undefined;
|
|
5016
|
+
try {
|
|
5017
|
+
const providerCalled = Promise.withResolvers<void>();
|
|
5018
|
+
let providerImages: ImageContent[] = [];
|
|
5019
|
+
const mock = createMockModel({ responses: [{ content: ["The staged image reached the model."] }] });
|
|
5020
|
+
const real = await createTerminalizationSession(cwd, (model, context, options) => {
|
|
5021
|
+
providerImages = context.messages.flatMap(message =>
|
|
5022
|
+
message.role === "user" && Array.isArray(message.content)
|
|
5023
|
+
? message.content.filter((block): block is ImageContent => block.type === "image")
|
|
5024
|
+
: [],
|
|
5025
|
+
);
|
|
5026
|
+
providerCalled.resolve();
|
|
5027
|
+
return mock.stream(model, context, options);
|
|
5028
|
+
});
|
|
5029
|
+
session = real.session;
|
|
5030
|
+
authStorage = real.authStorage;
|
|
5031
|
+
const send = realSendUserMessage(session);
|
|
5032
|
+
harness = await invocationHarness("sdk-provider-staged-image", cwd, {
|
|
5033
|
+
connectionLiveness: true,
|
|
5034
|
+
terminalAbortSeams: {
|
|
5035
|
+
getActivePromptHandle: () => real.session.activePromptHandle,
|
|
5036
|
+
waitForRunSettlement: handle =>
|
|
5037
|
+
real.session.agent.resourceLedger.waitForSettlement(handle, { graceMs: null }),
|
|
5038
|
+
},
|
|
5039
|
+
sendUserMessage: (content, options) => send(content, options),
|
|
5040
|
+
});
|
|
5041
|
+
harness.connect("image-owner");
|
|
5042
|
+
harness.connect("image-observer");
|
|
5043
|
+
session.subscribe(async event => {
|
|
5044
|
+
await harness?.emit(event.type, event);
|
|
5045
|
+
});
|
|
5046
|
+
|
|
5047
|
+
const id = await stageRuntimeImage(harness, bytes, "image-owner");
|
|
5048
|
+
const forgedOwnerPrompt = await harness.controlAs("image-observer", "turn.prompt", {
|
|
5049
|
+
text: "A different authenticated connection cannot redeem this image.",
|
|
5050
|
+
stagedImages: [{ id }],
|
|
5051
|
+
});
|
|
5052
|
+
expect(forgedOwnerPrompt).toMatchObject({ ok: false, error: { code: "image_upload_unavailable" } });
|
|
5053
|
+
const mixedPrompt = await harness.controlAs("image-owner", "turn.prompt", {
|
|
5054
|
+
text: "Direct and staged image blocks cannot be mixed.",
|
|
5055
|
+
images: [{ data: "cG5n", mimeType: "image/png" }],
|
|
5056
|
+
stagedImages: [{ id }],
|
|
5057
|
+
});
|
|
5058
|
+
expect(mixedPrompt).toMatchObject({ ok: false, error: { code: "invalid_input" } });
|
|
5059
|
+
expect(releaseCalls).toEqual([]);
|
|
5060
|
+
|
|
5061
|
+
const accepted = await harness.controlAs("image-owner", "turn.prompt", {
|
|
5062
|
+
text: "Inspect this losslessly staged image.",
|
|
5063
|
+
stagedImages: [{ id }],
|
|
5064
|
+
});
|
|
5065
|
+
expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5066
|
+
await providerCalled.promise;
|
|
5067
|
+
expect(providerImages).toEqual([{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" }]);
|
|
5068
|
+
await session.waitForIdle();
|
|
5069
|
+
await settledStatus(harness, "turn.result", {
|
|
5070
|
+
kind: "prompt",
|
|
5071
|
+
commandId: accepted.result?.commandId,
|
|
5072
|
+
turnId: accepted.result?.turnId,
|
|
5073
|
+
});
|
|
5074
|
+
for (let attempt = 0; releaseCalls.length === 0 && attempt < 200; attempt++) await Bun.sleep(10);
|
|
5075
|
+
expect(releaseCalls).toHaveLength(1);
|
|
5076
|
+
expect(
|
|
5077
|
+
await harness.controlAs("image-owner", "turn.prompt", {
|
|
5078
|
+
text: "The image descriptor is one-shot.",
|
|
5079
|
+
stagedImages: [{ id }],
|
|
5080
|
+
}),
|
|
5081
|
+
).toMatchObject({ ok: false, error: { code: "image_upload_unavailable" } });
|
|
5082
|
+
|
|
5083
|
+
const rejectedId = await stageRuntimeImage(harness, bytes, "image-owner");
|
|
5084
|
+
expect(
|
|
5085
|
+
await harness.controlAs("image-owner", "turn.prompt", {
|
|
5086
|
+
text: "Rejected before admission.",
|
|
5087
|
+
clientRef: " ",
|
|
5088
|
+
stagedImages: [{ id: rejectedId }],
|
|
5089
|
+
}),
|
|
5090
|
+
).toMatchObject({ ok: false, error: { code: "invalid_input" } });
|
|
5091
|
+
// Invalid clientRef admission must not consume or release staged bytes.
|
|
5092
|
+
expect(releaseCalls).toHaveLength(1);
|
|
5093
|
+
expect(await harness.controlAs("image-owner", "turn.image.discard", { id: rejectedId })).toMatchObject({
|
|
5094
|
+
ok: true,
|
|
5095
|
+
});
|
|
5096
|
+
|
|
5097
|
+
const disconnectedId = await stageRuntimeImage(harness, bytes, "image-owner");
|
|
5098
|
+
harness.disconnect("image-owner");
|
|
5099
|
+
expect(await harness.controlAs("image-owner", "turn.image.finish", { id: disconnectedId })).toMatchObject({
|
|
5100
|
+
ok: false,
|
|
5101
|
+
error: { code: "resource_gone" },
|
|
5102
|
+
});
|
|
5103
|
+
} finally {
|
|
5104
|
+
await session?.waitForIdle().catch(() => undefined);
|
|
5105
|
+
await harness?.stop();
|
|
5106
|
+
await session?.dispose();
|
|
5107
|
+
authStorage?.close();
|
|
5108
|
+
redeemSpy.mockRestore();
|
|
5109
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5110
|
+
}
|
|
5111
|
+
});
|
|
5112
|
+
|
|
5113
|
+
test("SDK-only real queued image refusal before agent_start fails Q26 and retires only its reservation", async () => {
|
|
5114
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-real-queued-image-refusal-"));
|
|
5115
|
+
const before = getPromptImageProcessBudgetSnapshot();
|
|
5116
|
+
const entered = Promise.withResolvers<void>();
|
|
5117
|
+
const releasePredecessor = Promise.withResolvers<void>();
|
|
5118
|
+
const enqueueReturned = Promise.withResolvers<void>();
|
|
5119
|
+
const released = Promise.withResolvers<void>();
|
|
5120
|
+
let releases = 0;
|
|
5121
|
+
let providerCalls = 0;
|
|
5122
|
+
const actualRefusals: QueuedRunStartFailure[] = [];
|
|
5123
|
+
let session: AgentSession | undefined;
|
|
5124
|
+
let authStorage: AuthStorage | undefined;
|
|
5125
|
+
let harness: InvocationHarness | undefined;
|
|
5126
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5127
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5128
|
+
this: PromptImageUploadStore,
|
|
5129
|
+
owner,
|
|
5130
|
+
ids,
|
|
5131
|
+
) {
|
|
5132
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5133
|
+
return {
|
|
5134
|
+
images: reservation.images,
|
|
5135
|
+
release: () => {
|
|
5136
|
+
releases++;
|
|
5137
|
+
reservation.release();
|
|
5138
|
+
released.resolve();
|
|
5139
|
+
},
|
|
5140
|
+
};
|
|
5141
|
+
});
|
|
5142
|
+
try {
|
|
5143
|
+
const real = await createTerminalizationSession(cwd, (model, context, options) => {
|
|
5144
|
+
const call = ++providerCalls;
|
|
5145
|
+
return createMockModel({
|
|
5146
|
+
responses: [
|
|
5147
|
+
async () => {
|
|
5148
|
+
if (call === 1) {
|
|
5149
|
+
entered.resolve();
|
|
5150
|
+
await releasePredecessor.promise;
|
|
5151
|
+
}
|
|
5152
|
+
return { content: ["independent completion"] };
|
|
5153
|
+
},
|
|
5154
|
+
],
|
|
5155
|
+
}).stream(model, context, options);
|
|
5156
|
+
});
|
|
5157
|
+
session = real.session;
|
|
5158
|
+
authStorage = real.authStorage;
|
|
5159
|
+
const send = realSendUserMessage(session);
|
|
5160
|
+
harness = await invocationHarness("real-queued-image-refusal", cwd, {
|
|
5161
|
+
connectionLiveness: true,
|
|
5162
|
+
isIdle: () => !real.session.isStreaming,
|
|
5163
|
+
terminalAbortSeams: {
|
|
5164
|
+
getActivePromptHandle: () => real.session.activePromptHandle,
|
|
5165
|
+
waitForRunSettlement: handle =>
|
|
5166
|
+
real.session.agent.resourceLedger.waitForSettlement(handle, { graceMs: null }),
|
|
5167
|
+
},
|
|
5168
|
+
sendUserMessage: async (content, options) => {
|
|
5169
|
+
// Exercise the real follow-up queue with the staged-image SDK prompt.
|
|
5170
|
+
const image = Array.isArray(content) && content.some(block => block.type === "image");
|
|
5171
|
+
await send(
|
|
5172
|
+
content,
|
|
5173
|
+
image
|
|
5174
|
+
? {
|
|
5175
|
+
...options,
|
|
5176
|
+
deliverAs: "followUp",
|
|
5177
|
+
onQueuedRunStartFailed: failure => {
|
|
5178
|
+
actualRefusals.push(failure);
|
|
5179
|
+
options?.onQueuedRunStartFailed?.(failure);
|
|
5180
|
+
},
|
|
5181
|
+
}
|
|
5182
|
+
: options,
|
|
5183
|
+
);
|
|
5184
|
+
if (image) enqueueReturned.resolve();
|
|
5185
|
+
},
|
|
5186
|
+
});
|
|
5187
|
+
harness.connect("client");
|
|
5188
|
+
session.subscribe(event => harness?.emit(event.type, event));
|
|
5189
|
+
expect(await harness.control("turn.prompt", { text: "hold predecessor" })).toMatchObject({ ok: true });
|
|
5190
|
+
await entered.promise;
|
|
5191
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5192
|
+
const accepted = await harness.control("turn.prompt", {
|
|
5193
|
+
text: "queued image requires a model",
|
|
5194
|
+
stagedImages: [{ id }],
|
|
5195
|
+
clientRef: "real-refused-image",
|
|
5196
|
+
});
|
|
5197
|
+
expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5198
|
+
await enqueueReturned.promise;
|
|
5199
|
+
expect(releases).toBe(0);
|
|
5200
|
+
expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBeGreaterThan(before.sourceBytes);
|
|
5201
|
+
session.agent.setModel(undefined);
|
|
5202
|
+
releasePredecessor.resolve();
|
|
5203
|
+
const failed = await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "real-refused-image" });
|
|
5204
|
+
expect(failed).toMatchObject({
|
|
5205
|
+
status: "failed",
|
|
5206
|
+
outcome: { kind: "failed" },
|
|
5207
|
+
error: { code: "internal", message: "Prompt submission failed." },
|
|
5208
|
+
});
|
|
5209
|
+
await released.promise;
|
|
5210
|
+
await session.waitForIdle();
|
|
5211
|
+
expect(actualRefusals).toHaveLength(1);
|
|
5212
|
+
expect(actualRefusals[0]).toMatchObject({
|
|
5213
|
+
disposition: "not-started",
|
|
5214
|
+
error: { message: "No model configured" },
|
|
5215
|
+
});
|
|
5216
|
+
expect(providerCalls).toBe(1);
|
|
5217
|
+
expect(releases).toBe(1);
|
|
5218
|
+
expect(getPromptImageProcessBudgetSnapshot()).toEqual(before);
|
|
5219
|
+
// The same session store can stage/discard bytes again after retirement.
|
|
5220
|
+
const laterImage = await stageRuntimeImage(harness, makeTestPng());
|
|
5221
|
+
expect(await harness.control("turn.image.discard", { id: laterImage })).toMatchObject({ ok: true });
|
|
5222
|
+
expect(getPromptImageProcessBudgetSnapshot()).toEqual(before);
|
|
5223
|
+
session.agent.setModel(real.model);
|
|
5224
|
+
const fresh = await harness.control("turn.prompt", { text: "restored model starts independent work" });
|
|
5225
|
+
expect(
|
|
5226
|
+
await settledStatus(harness, "turn.result", {
|
|
5227
|
+
kind: "prompt",
|
|
5228
|
+
commandId: fresh.result?.commandId,
|
|
5229
|
+
turnId: fresh.result?.turnId,
|
|
5230
|
+
}),
|
|
5231
|
+
).toMatchObject({ status: "terminal_ok" });
|
|
5232
|
+
expect(await harness.query("turn.result", { kind: "prompt", clientRef: "real-refused-image" })).toMatchObject({
|
|
5233
|
+
result: { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId, status: "failed" },
|
|
5234
|
+
});
|
|
5235
|
+
expect(providerCalls).toBe(2);
|
|
5236
|
+
expect(releases).toBe(1);
|
|
5237
|
+
} finally {
|
|
5238
|
+
releasePredecessor.resolve();
|
|
5239
|
+
await session?.waitForIdle().catch(() => undefined);
|
|
5240
|
+
await harness?.stop();
|
|
5241
|
+
await session?.dispose();
|
|
5242
|
+
authStorage?.close();
|
|
5243
|
+
redeemSpy.mockRestore();
|
|
5244
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5245
|
+
}
|
|
5246
|
+
});
|
|
5247
|
+
|
|
5248
|
+
test.each([
|
|
5249
|
+
"pending-producer",
|
|
5250
|
+
"returned-producer",
|
|
5251
|
+
"promoted-returned-producer",
|
|
5252
|
+
"post-start",
|
|
5253
|
+
"same-text-rejection",
|
|
5254
|
+
"late-refusal",
|
|
5255
|
+
] as const)("SDK-only queued image refusal respects producer and execution authority (%s)", async mode => {
|
|
5256
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-refusal-authority-"));
|
|
5257
|
+
const before = getPromptImageProcessBudgetSnapshot();
|
|
5258
|
+
const producer = Promise.withResolvers<void>();
|
|
5259
|
+
const returned = Promise.withResolvers<void>();
|
|
5260
|
+
const released = Promise.withResolvers<void>();
|
|
5261
|
+
const proofRequested = Promise.withResolvers<void>();
|
|
5262
|
+
const settlement = Promise.withResolvers<{ status: "settled" }>();
|
|
5263
|
+
let callbacks: PreflightHooks | undefined;
|
|
5264
|
+
let releases = 0;
|
|
5265
|
+
const proofHandles: string[] = [];
|
|
5266
|
+
const cleanupReservations: Array<() => void> = [];
|
|
5267
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5268
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5269
|
+
this: PromptImageUploadStore,
|
|
5270
|
+
owner,
|
|
5271
|
+
ids,
|
|
5272
|
+
) {
|
|
5273
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5274
|
+
cleanupReservations.push(reservation.release);
|
|
5275
|
+
return {
|
|
5276
|
+
images: reservation.images,
|
|
5277
|
+
release: () => {
|
|
5278
|
+
releases++;
|
|
5279
|
+
reservation.release();
|
|
5280
|
+
released.resolve();
|
|
5281
|
+
},
|
|
5282
|
+
};
|
|
5283
|
+
});
|
|
5284
|
+
let harness: InvocationHarness | undefined;
|
|
5285
|
+
try {
|
|
5286
|
+
harness = await invocationHarness(`image-refusal-${mode}`, cwd, {
|
|
5287
|
+
connectionLiveness: true,
|
|
5288
|
+
// Deliberately idle snapshot, then actual queue diversion.
|
|
5289
|
+
isIdle: () => true,
|
|
5290
|
+
terminalAbortSeams: {
|
|
5291
|
+
getActivePromptHandle: () => (mode === "post-start" ? "started-image-run" : undefined),
|
|
5292
|
+
waitForRunSettlement: handle => {
|
|
5293
|
+
proofHandles.push(handle);
|
|
5294
|
+
proofRequested.resolve();
|
|
5295
|
+
return settlement.promise;
|
|
5296
|
+
},
|
|
5297
|
+
},
|
|
5298
|
+
sendUserMessage: async (_content, options) => {
|
|
5299
|
+
callbacks = options;
|
|
5300
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5301
|
+
options?.onDispatchDisposition?.({ startsOwnRun: false });
|
|
5302
|
+
try {
|
|
5303
|
+
await producer.promise;
|
|
5304
|
+
} finally {
|
|
5305
|
+
returned.resolve();
|
|
5306
|
+
}
|
|
5307
|
+
},
|
|
5308
|
+
});
|
|
5309
|
+
harness.connect("client");
|
|
5310
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5311
|
+
const accepted = await harness.control("turn.prompt", {
|
|
5312
|
+
text: "image queue authority",
|
|
5313
|
+
stagedImages: [{ id }],
|
|
5314
|
+
clientRef: `authority-${mode}`,
|
|
5315
|
+
});
|
|
5316
|
+
expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5317
|
+
if (mode === "promoted-returned-producer") callbacks?.onQueuedPromoted?.({ startsOwnRun: true });
|
|
5318
|
+
if (mode === "returned-producer" || mode === "promoted-returned-producer") {
|
|
5319
|
+
producer.resolve();
|
|
5320
|
+
await returned.promise;
|
|
5321
|
+
// Yield through the submit.then fallback: the diverted idle snapshot is not completion.
|
|
5322
|
+
expect(await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` })).toMatchObject({
|
|
5323
|
+
result: { status: "accepted" },
|
|
5324
|
+
});
|
|
5325
|
+
}
|
|
5326
|
+
if (mode !== "promoted-returned-producer") callbacks?.onQueuedPromoted?.({ startsOwnRun: true });
|
|
5327
|
+
if (mode === "post-start") await harness.emit("agent_start");
|
|
5328
|
+
const failure: QueuedRunStartFailure = { disposition: "not-started", error: new Error("No model configured") };
|
|
5329
|
+
if (mode === "same-text-rejection" || mode === "late-refusal") {
|
|
5330
|
+
producer.reject(
|
|
5331
|
+
Object.assign(
|
|
5332
|
+
new Error("No model configured"),
|
|
5333
|
+
mode === "late-refusal" ? { code: "original_failure" } : {},
|
|
5334
|
+
),
|
|
5335
|
+
);
|
|
5336
|
+
} else {
|
|
5337
|
+
callbacks?.onQueuedRunStartFailed?.(failure);
|
|
5338
|
+
callbacks?.onQueuedRunStartFailed?.(failure);
|
|
5339
|
+
}
|
|
5340
|
+
if (mode === "post-start") {
|
|
5341
|
+
expect(await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` })).toMatchObject({
|
|
5342
|
+
result: { status: "in_flight" },
|
|
5343
|
+
});
|
|
5344
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "done" }] });
|
|
5345
|
+
await proofRequested.promise;
|
|
5346
|
+
expect(proofHandles).toEqual(["started-image-run"]);
|
|
5347
|
+
expect(releases).toBe(0);
|
|
5348
|
+
settlement.resolve({ status: "settled" });
|
|
5349
|
+
} else {
|
|
5350
|
+
expect(
|
|
5351
|
+
await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: `authority-${mode}` }),
|
|
5352
|
+
).toMatchObject({
|
|
5353
|
+
status: "failed",
|
|
5354
|
+
outcome: { kind: "failed" },
|
|
5355
|
+
error: {
|
|
5356
|
+
code: mode === "late-refusal" ? "original_failure" : "internal",
|
|
5357
|
+
message: "Prompt submission failed.",
|
|
5358
|
+
},
|
|
5359
|
+
});
|
|
5360
|
+
if (mode !== "returned-producer" && mode !== "promoted-returned-producer") {
|
|
5361
|
+
expect(releases).toBe(0);
|
|
5362
|
+
expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBeGreaterThan(before.sourceBytes);
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
producer.resolve();
|
|
5366
|
+
await returned.promise;
|
|
5367
|
+
if (mode === "late-refusal") callbacks?.onQueuedRunStartFailed?.(failure);
|
|
5368
|
+
if (mode === "same-text-rejection") {
|
|
5369
|
+
// A text-identical generic rejection is not proof; teardown cannot release either.
|
|
5370
|
+
await harness.stop();
|
|
5371
|
+
expect(releases).toBe(0);
|
|
5372
|
+
expect(proofHandles).toEqual([]);
|
|
5373
|
+
} else {
|
|
5374
|
+
await released.promise;
|
|
5375
|
+
expect(releases).toBe(1);
|
|
5376
|
+
expect(getPromptImageProcessBudgetSnapshot()).toEqual(before);
|
|
5377
|
+
callbacks?.onQueuedRunStartFailed?.(failure);
|
|
5378
|
+
if (mode !== "post-start") callbacks?.onQueuedPromoted?.({ startsOwnRun: true });
|
|
5379
|
+
await harness.emit("agent_start");
|
|
5380
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "later" }] });
|
|
5381
|
+
expect(await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` })).toMatchObject({
|
|
5382
|
+
result: { status: mode === "post-start" ? "terminal_ok" : "failed" },
|
|
5383
|
+
});
|
|
5384
|
+
expect(releases).toBe(1);
|
|
5385
|
+
if (mode === "late-refusal")
|
|
5386
|
+
expect(
|
|
5387
|
+
await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` }),
|
|
5388
|
+
).toMatchObject({ result: { error: { code: "original_failure" } } });
|
|
5389
|
+
}
|
|
5390
|
+
} finally {
|
|
5391
|
+
producer.resolve();
|
|
5392
|
+
settlement.resolve({ status: "settled" });
|
|
5393
|
+
await harness?.stop().catch(() => undefined);
|
|
5394
|
+
for (const release of cleanupReservations) release();
|
|
5395
|
+
redeemSpy.mockRestore();
|
|
5396
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5397
|
+
}
|
|
5398
|
+
});
|
|
5399
|
+
|
|
5400
|
+
test("SDK-only staged prompts reconcile clientRef before redeeming during preflight and in-flight execution", async () => {
|
|
5401
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-staged-client-ref-"));
|
|
5402
|
+
const allowAcceptance = Promise.withResolvers<void>();
|
|
5403
|
+
const producer = Promise.withResolvers<void>();
|
|
5404
|
+
const entered = Promise.withResolvers<void>();
|
|
5405
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5406
|
+
let releases = 0;
|
|
5407
|
+
let sends = 0;
|
|
5408
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5409
|
+
this: PromptImageUploadStore,
|
|
5410
|
+
owner,
|
|
5411
|
+
ids,
|
|
5412
|
+
) {
|
|
5413
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5414
|
+
return {
|
|
5415
|
+
images: reservation.images,
|
|
5416
|
+
release: () => {
|
|
5417
|
+
releases++;
|
|
5418
|
+
reservation.release();
|
|
5419
|
+
},
|
|
5420
|
+
};
|
|
5421
|
+
});
|
|
5422
|
+
let harness: InvocationHarness | undefined;
|
|
5423
|
+
try {
|
|
5424
|
+
harness = await invocationHarness("staged-client-ref", cwd, {
|
|
5425
|
+
connectionLiveness: true,
|
|
5426
|
+
sendUserMessage: async (_content, options) => {
|
|
5427
|
+
sends++;
|
|
5428
|
+
entered.resolve();
|
|
5429
|
+
await allowAcceptance.promise;
|
|
5430
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5431
|
+
await producer.promise;
|
|
5432
|
+
},
|
|
5433
|
+
});
|
|
5434
|
+
harness.connect("image-owner");
|
|
5435
|
+
harness.connect("reconnecting-client");
|
|
5436
|
+
const id = await stageRuntimeImage(harness, makeTestPng(), "image-owner");
|
|
5437
|
+
const input = { text: "Original staged producer", clientRef: "staged-ref", stagedImages: [{ id }] };
|
|
5438
|
+
const first = harness.controlAs("image-owner", "turn.prompt", input);
|
|
5439
|
+
void first.catch(() => {});
|
|
5440
|
+
await entered.promise;
|
|
5441
|
+
// Ordered controls serialize per session, even on separate connections.
|
|
5442
|
+
// A retry arriving during preflight cannot finish before the first receipt.
|
|
5443
|
+
const duplicate = harness.controlAs("reconnecting-client", "turn.prompt", input);
|
|
5444
|
+
void duplicate.catch(() => {});
|
|
5445
|
+
expect(redeemSpy.mock.calls).toHaveLength(1);
|
|
5446
|
+
expect(sends).toBe(1);
|
|
5447
|
+
expect(releases).toBe(0);
|
|
5448
|
+
allowAcceptance.resolve();
|
|
5449
|
+
const accepted = await first;
|
|
5450
|
+
expect(accepted).toMatchObject({ ok: true, result: { accepted: true, clientRef: "staged-ref" } });
|
|
5451
|
+
expect(await duplicate).toMatchObject({ ok: false, error: { code: "client_ref_conflict" } });
|
|
5452
|
+
await harness.emit("agent_start");
|
|
5453
|
+
// The original receipt may have been lost. Reconnection never replays it,
|
|
5454
|
+
// and cannot replace the original producer's retained image ownership.
|
|
5455
|
+
expect(await harness.controlAs("image-owner", "turn.prompt", input)).toMatchObject({
|
|
5456
|
+
ok: false,
|
|
5457
|
+
error: { code: "client_ref_conflict" },
|
|
5458
|
+
});
|
|
5459
|
+
expect(await harness.query("turn.result", { kind: "prompt", clientRef: "staged-ref" })).toMatchObject({
|
|
5460
|
+
result: { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId, status: "in_flight" },
|
|
5461
|
+
});
|
|
5462
|
+
expect(redeemSpy.mock.calls).toHaveLength(1);
|
|
5463
|
+
expect(sends).toBe(1);
|
|
5464
|
+
expect(releases).toBe(0);
|
|
5465
|
+
await harness.emit("agent_end", {
|
|
5466
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "done" }],
|
|
5467
|
+
});
|
|
5468
|
+
producer.resolve();
|
|
5469
|
+
await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "staged-ref" });
|
|
5470
|
+
expect(await harness.controlAs("image-owner", "turn.prompt", input)).toMatchObject({
|
|
5471
|
+
ok: false,
|
|
5472
|
+
error: { code: "client_ref_conflict" },
|
|
5473
|
+
});
|
|
5474
|
+
expect(redeemSpy.mock.calls).toHaveLength(1);
|
|
5475
|
+
expect(sends).toBe(1);
|
|
5476
|
+
} finally {
|
|
5477
|
+
allowAcceptance.resolve();
|
|
5478
|
+
producer.resolve();
|
|
5479
|
+
try {
|
|
5480
|
+
await harness?.stop();
|
|
5481
|
+
} finally {
|
|
5482
|
+
redeemSpy.mockRestore();
|
|
5483
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
});
|
|
5487
|
+
|
|
5488
|
+
test("SDK-only staged image reservations follow the exact consuming run across disconnect and unrelated end", async () => {
|
|
5489
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-staged-image-run-owner-"));
|
|
5490
|
+
const bytes = makeTestPng();
|
|
5491
|
+
const releases: string[] = [];
|
|
5492
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5493
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5494
|
+
this: PromptImageUploadStore,
|
|
5495
|
+
owner,
|
|
5496
|
+
ids,
|
|
5497
|
+
) {
|
|
5498
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5499
|
+
return {
|
|
5500
|
+
images: reservation.images,
|
|
5501
|
+
release: () => {
|
|
5502
|
+
releases.push("released");
|
|
5503
|
+
reservation.release();
|
|
5504
|
+
},
|
|
5505
|
+
};
|
|
5506
|
+
});
|
|
5507
|
+
let harness: InvocationHarness | undefined;
|
|
5508
|
+
const imagePromotions: Array<PreflightHooks["onQueuedPromoted"]> = [];
|
|
5509
|
+
let promptDeadlineMs = 60_000;
|
|
5510
|
+
try {
|
|
5511
|
+
harness = await invocationHarness("staged-image-run-owner", cwd, {
|
|
5512
|
+
connectionLiveness: true,
|
|
5513
|
+
settings: {
|
|
5514
|
+
get: (key: string) =>
|
|
5515
|
+
key === "sdk.promptDeadlineMs"
|
|
5516
|
+
? promptDeadlineMs
|
|
5517
|
+
: key === "sdk.promptMaxRuntimeMs"
|
|
5518
|
+
? 60_000
|
|
5519
|
+
: undefined,
|
|
5520
|
+
} as unknown as Settings,
|
|
5521
|
+
sendUserMessage: async (content, options) => {
|
|
5522
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5523
|
+
if (content === "root run") await neverSettlingPromise();
|
|
5524
|
+
if (options?.deliverAs === "followUp") {
|
|
5525
|
+
options.onDispatchDisposition?.({ startsOwnRun: false });
|
|
5526
|
+
options.preflightSignal?.addEventListener("abort", () => options.onQueuedPromoted?.({ removed: true }), {
|
|
5527
|
+
once: true,
|
|
5528
|
+
});
|
|
5529
|
+
return;
|
|
5530
|
+
}
|
|
5531
|
+
if (Array.isArray(content)) {
|
|
5532
|
+
expect(
|
|
5533
|
+
content.some(block => typeof block === "object" && block !== null && block.type === "image"),
|
|
5534
|
+
).toBe(true);
|
|
5535
|
+
options?.onDispatchDisposition?.({ startsOwnRun: false });
|
|
5536
|
+
imagePromotions.push(options?.onQueuedPromoted);
|
|
5537
|
+
}
|
|
5538
|
+
},
|
|
5539
|
+
});
|
|
5540
|
+
harness.connect("root-owner");
|
|
5541
|
+
harness.connect("image-owner");
|
|
5542
|
+
harness.connect("cancel-owner");
|
|
5543
|
+
const root = await harness.controlAs("root-owner", "turn.prompt", { text: "root run" });
|
|
5544
|
+
expect(root).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5545
|
+
await harness.emit("agent_start");
|
|
5546
|
+
const id = await stageRuntimeImage(harness, bytes, "image-owner");
|
|
5547
|
+
const image = await harness.controlAs("image-owner", "turn.prompt", {
|
|
5548
|
+
text: "queued image run",
|
|
5549
|
+
stagedImages: [{ id }],
|
|
5550
|
+
});
|
|
5551
|
+
expect(image).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5552
|
+
const correlation = { commandId: image.result?.commandId, turnId: image.result?.turnId };
|
|
5553
|
+
const cancelledId = await stageRuntimeImage(harness, bytes, "cancel-owner");
|
|
5554
|
+
const cancelled = await harness.controlAs("cancel-owner", "turn.prompt", {
|
|
5555
|
+
text: "queued image that will be removed",
|
|
5556
|
+
stagedImages: [{ id: cancelledId }],
|
|
5557
|
+
});
|
|
5558
|
+
expect(cancelled).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5559
|
+
const cancelledCorrelation = { commandId: cancelled.result?.commandId, turnId: cancelled.result?.turnId };
|
|
5560
|
+
expect(imagePromotions).toHaveLength(2);
|
|
5561
|
+
expect(releases).toEqual([]);
|
|
5562
|
+
|
|
5563
|
+
// The stage lease was redeemed already. Losing its source connection and
|
|
5564
|
+
// completing the unrelated root run must not release quota owned by the
|
|
5565
|
+
// still-queued image submission.
|
|
5566
|
+
harness.disconnect("image-owner");
|
|
5567
|
+
await harness.emit("agent_end", {
|
|
5568
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "root finished" }],
|
|
5569
|
+
});
|
|
5570
|
+
expect(releases).toEqual([]);
|
|
5571
|
+
|
|
5572
|
+
imagePromotions[1]?.({ startsOwnRun: false, removed: true });
|
|
5573
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...cancelledCorrelation })).toMatchObject({
|
|
5574
|
+
status: "failed",
|
|
5575
|
+
error: { code: "cancelled" },
|
|
5576
|
+
});
|
|
5577
|
+
expect(releases).toEqual(["released"]);
|
|
5578
|
+
|
|
5579
|
+
imagePromotions[0]?.({ startsOwnRun: true });
|
|
5580
|
+
await harness.emit("agent_start");
|
|
5581
|
+
expect(releases).toEqual(["released"]);
|
|
5582
|
+
promptDeadlineMs = 25;
|
|
5583
|
+
const unrelatedFollowUp = await harness.controlAs("cancel-owner", "turn.follow_up", {
|
|
5584
|
+
text: "unrelated follow-up past its admission deadline",
|
|
5585
|
+
});
|
|
5586
|
+
expect(unrelatedFollowUp).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5587
|
+
await Bun.sleep(100);
|
|
5588
|
+
expect(
|
|
5589
|
+
await harness.query("turn.result", {
|
|
5590
|
+
kind: "prompt",
|
|
5591
|
+
commandId: unrelatedFollowUp.result?.commandId,
|
|
5592
|
+
turnId: unrelatedFollowUp.result?.turnId,
|
|
5593
|
+
}),
|
|
5594
|
+
).toMatchObject({ result: { status: "accepted" } });
|
|
5595
|
+
expect(releases).toEqual(["released"]);
|
|
5596
|
+
await harness.emit("agent_end", {
|
|
5597
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "image finished" }],
|
|
5598
|
+
});
|
|
5599
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
|
|
5600
|
+
status: "terminal_ok",
|
|
5601
|
+
});
|
|
5602
|
+
expect(releases).toEqual(["released", "released"]);
|
|
5603
|
+
} finally {
|
|
5604
|
+
try {
|
|
5605
|
+
await harness?.stop();
|
|
5606
|
+
} finally {
|
|
5607
|
+
redeemSpy.mockRestore();
|
|
5608
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5609
|
+
}
|
|
5610
|
+
}
|
|
5611
|
+
});
|
|
5612
|
+
|
|
5613
|
+
test("SDK-only terminal preflight abort retains staged-image quota until submission settlement", async () => {
|
|
5614
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-preflight-abort-"));
|
|
5615
|
+
const sessionId = "image-preflight-abort";
|
|
5616
|
+
const sessionFile = path.join(cwd, "session.json");
|
|
5617
|
+
await Bun.write(sessionFile, "");
|
|
5618
|
+
const reconciliationStore = createReconciliationStore({ sessionFile, sessionId });
|
|
5619
|
+
const producer = Promise.withResolvers<void>();
|
|
5620
|
+
const submissionStarted = Promise.withResolvers<void>();
|
|
5621
|
+
const preflightAborted = Promise.withResolvers<void>();
|
|
5622
|
+
const released = Promise.withResolvers<void>();
|
|
5623
|
+
let releases = 0;
|
|
5624
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5625
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5626
|
+
this: PromptImageUploadStore,
|
|
5627
|
+
owner,
|
|
5628
|
+
ids,
|
|
5629
|
+
) {
|
|
5630
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5631
|
+
return {
|
|
5632
|
+
images: reservation.images,
|
|
5633
|
+
release: () => {
|
|
5634
|
+
releases++;
|
|
5635
|
+
reservation.release();
|
|
5636
|
+
released.resolve();
|
|
5637
|
+
},
|
|
5638
|
+
};
|
|
5639
|
+
});
|
|
5640
|
+
let harness: InvocationHarness | undefined;
|
|
5641
|
+
try {
|
|
5642
|
+
harness = await invocationHarness(sessionId, cwd, {
|
|
5643
|
+
connectionLiveness: true,
|
|
5644
|
+
terminalAbortSeams: {
|
|
5645
|
+
getReconciliationStore: () => reconciliationStore,
|
|
5646
|
+
getTerminalTurnEpoch: () => undefined,
|
|
5647
|
+
getActivePromptHandle: () => undefined,
|
|
5648
|
+
cancelPendingPreflightForTerminalAbort: () => {},
|
|
5649
|
+
abortPromptAndWaitWithTerminal: async () => {
|
|
5650
|
+
throw new Error("idle preflight abort must not stop an active run");
|
|
5651
|
+
},
|
|
5652
|
+
},
|
|
5653
|
+
sendUserMessage: async (content, options) => {
|
|
5654
|
+
expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
|
|
5655
|
+
options?.preflightSignal?.addEventListener("abort", () => preflightAborted.resolve(), { once: true });
|
|
5656
|
+
submissionStarted.resolve();
|
|
5657
|
+
await producer.promise;
|
|
5658
|
+
expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
|
|
5659
|
+
},
|
|
5660
|
+
});
|
|
5661
|
+
harness.connect("client");
|
|
5662
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5663
|
+
const prompt = harness.control("turn.prompt", {
|
|
5664
|
+
text: "hold the pending staged-image producer",
|
|
5665
|
+
stagedImages: [{ id }],
|
|
5666
|
+
});
|
|
5667
|
+
await submissionStarted.promise;
|
|
5668
|
+
const abort = await harness.controlAs("client", "turn.abort", { mode: "terminal" }, "image-preflight-abort-key");
|
|
5669
|
+
expect(abort).toMatchObject({
|
|
5670
|
+
ok: true,
|
|
5671
|
+
result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
|
|
5672
|
+
});
|
|
5673
|
+
expect(await prompt).toMatchObject({ ok: false, error: { code: "busy" } });
|
|
5674
|
+
await preflightAborted.promise;
|
|
5675
|
+
expect(releases).toBe(0);
|
|
5676
|
+
|
|
5677
|
+
producer.resolve();
|
|
5678
|
+
await released.promise;
|
|
5679
|
+
expect(releases).toBe(1);
|
|
5680
|
+
} finally {
|
|
5681
|
+
producer.resolve();
|
|
5682
|
+
try {
|
|
5683
|
+
await harness?.stop();
|
|
5684
|
+
} finally {
|
|
5685
|
+
redeemSpy.mockRestore();
|
|
5686
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5687
|
+
}
|
|
5688
|
+
}
|
|
5689
|
+
});
|
|
5690
|
+
|
|
5691
|
+
test("SDK-only shutdown after acceptance retains unadopted staged-image quota until submission settlement", async () => {
|
|
5692
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-accepted-unadopted-"));
|
|
5693
|
+
const producer = Promise.withResolvers<void>();
|
|
5694
|
+
const released = Promise.withResolvers<void>();
|
|
5695
|
+
let releases = 0;
|
|
5696
|
+
let preflightSignal: AbortSignal | undefined;
|
|
5697
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5698
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5699
|
+
this: PromptImageUploadStore,
|
|
5700
|
+
owner,
|
|
5701
|
+
ids,
|
|
5702
|
+
) {
|
|
5703
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5704
|
+
return {
|
|
5705
|
+
images: reservation.images,
|
|
5706
|
+
release: () => {
|
|
5707
|
+
releases++;
|
|
5708
|
+
reservation.release();
|
|
5709
|
+
released.resolve();
|
|
5710
|
+
},
|
|
5711
|
+
};
|
|
5712
|
+
});
|
|
5713
|
+
let harness: InvocationHarness | undefined;
|
|
5714
|
+
try {
|
|
5715
|
+
harness = await invocationHarness("image-accepted-unadopted", cwd, {
|
|
5716
|
+
connectionLiveness: true,
|
|
5717
|
+
sendUserMessage: async (content, options) => {
|
|
5718
|
+
preflightSignal = options?.preflightSignal;
|
|
5719
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5720
|
+
await producer.promise;
|
|
5721
|
+
expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
|
|
5722
|
+
},
|
|
5723
|
+
});
|
|
5724
|
+
harness.connect("client");
|
|
5725
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5726
|
+
const accepted = await harness.control("turn.prompt", {
|
|
5727
|
+
text: "accepted producer has not adopted an execution",
|
|
5728
|
+
stagedImages: [{ id }],
|
|
5729
|
+
});
|
|
5730
|
+
expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5731
|
+
expect(releases).toBe(0);
|
|
5732
|
+
await harness.stop();
|
|
5733
|
+
expect(preflightSignal?.aborted).toBe(true);
|
|
5734
|
+
expect(releases).toBe(0);
|
|
5735
|
+
producer.resolve();
|
|
5736
|
+
await released.promise;
|
|
5737
|
+
expect(releases).toBe(1);
|
|
5738
|
+
} finally {
|
|
5739
|
+
producer.resolve();
|
|
5740
|
+
try {
|
|
5741
|
+
await harness?.stop();
|
|
5742
|
+
} finally {
|
|
5743
|
+
redeemSpy.mockRestore();
|
|
5744
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5745
|
+
}
|
|
5746
|
+
}
|
|
5747
|
+
});
|
|
5748
|
+
|
|
5749
|
+
test("SDK-only shutdown during durable preflight acceptance retains staged-image quota until submission settlement", async () => {
|
|
5750
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-shutdown-preflight-"));
|
|
5751
|
+
const sessionId = "image-shutdown-preflight";
|
|
5752
|
+
const sessionFile = path.join(cwd, "session.json");
|
|
5753
|
+
await Bun.write(sessionFile, "");
|
|
5754
|
+
const store = createReconciliationStore({ sessionFile, sessionId });
|
|
5755
|
+
const clientRef = "image-shutdown-preflight-client";
|
|
5756
|
+
const producer = Promise.withResolvers<void>();
|
|
5757
|
+
const acceptanceHeld = Promise.withResolvers<void>();
|
|
5758
|
+
const resumeAcceptance = Promise.withResolvers<void>();
|
|
5759
|
+
const acceptanceResumed = Promise.withResolvers<void>();
|
|
5760
|
+
const submissionFinished = Promise.withResolvers<void>();
|
|
5761
|
+
const released = Promise.withResolvers<void>();
|
|
5762
|
+
let submissionEntered = false;
|
|
5763
|
+
let held = false;
|
|
5764
|
+
let releases = 0;
|
|
5765
|
+
const originalRename = fsPromises.rename;
|
|
5766
|
+
const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
|
|
5767
|
+
if (!held && String(to) === store.path) {
|
|
5768
|
+
const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
|
|
5769
|
+
if (document.records.some(record => record.clientRef === clientRef && record.status === "accepted")) {
|
|
5770
|
+
held = true;
|
|
5771
|
+
acceptanceHeld.resolve();
|
|
5772
|
+
await resumeAcceptance.promise;
|
|
5773
|
+
}
|
|
5774
|
+
}
|
|
5775
|
+
await originalRename(from, to);
|
|
5776
|
+
});
|
|
5777
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5778
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5779
|
+
this: PromptImageUploadStore,
|
|
5780
|
+
owner,
|
|
5781
|
+
ids,
|
|
5782
|
+
) {
|
|
5783
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5784
|
+
return {
|
|
5785
|
+
images: reservation.images,
|
|
5786
|
+
release: () => {
|
|
5787
|
+
releases++;
|
|
5788
|
+
reservation.release();
|
|
5789
|
+
released.resolve();
|
|
5790
|
+
},
|
|
5791
|
+
};
|
|
5792
|
+
});
|
|
5793
|
+
let harness: InvocationHarness | undefined;
|
|
5794
|
+
let preflightSignal: AbortSignal | undefined;
|
|
5795
|
+
try {
|
|
5796
|
+
harness = await invocationHarness(sessionId, cwd, {
|
|
5797
|
+
connectionLiveness: true,
|
|
5798
|
+
terminalAbortSeams: { getReconciliationStore: () => store },
|
|
5799
|
+
sendUserMessage: async (content, options) => {
|
|
5800
|
+
expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
|
|
5801
|
+
submissionEntered = true;
|
|
5802
|
+
preflightSignal = options?.preflightSignal;
|
|
5803
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5804
|
+
acceptanceResumed.resolve();
|
|
5805
|
+
await producer.promise;
|
|
5806
|
+
submissionFinished.resolve();
|
|
5807
|
+
expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
|
|
5808
|
+
},
|
|
5809
|
+
});
|
|
5810
|
+
harness.connect("client");
|
|
5811
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5812
|
+
void harness
|
|
5813
|
+
.control("turn.prompt", {
|
|
5814
|
+
text: "hold the staged-image producer through shutdown acceptance",
|
|
5815
|
+
stagedImages: [{ id }],
|
|
5816
|
+
clientRef,
|
|
5817
|
+
})
|
|
5818
|
+
.catch(() => undefined);
|
|
5819
|
+
await acceptanceHeld.promise;
|
|
5820
|
+
expect(releases).toBe(0);
|
|
5821
|
+
|
|
5822
|
+
await harness.stop();
|
|
5823
|
+
expect(preflightSignal?.aborted).toBe(true);
|
|
5824
|
+
expect(releases).toBe(0);
|
|
5825
|
+
|
|
5826
|
+
resumeAcceptance.resolve();
|
|
5827
|
+
await acceptanceResumed.promise;
|
|
5828
|
+
expect(releases).toBe(0);
|
|
5829
|
+
producer.resolve();
|
|
5830
|
+
await submissionFinished.promise;
|
|
5831
|
+
await released.promise;
|
|
5832
|
+
expect(releases).toBe(1);
|
|
5833
|
+
} finally {
|
|
5834
|
+
resumeAcceptance.resolve();
|
|
5835
|
+
producer.resolve();
|
|
5836
|
+
if (submissionEntered) await submissionFinished.promise;
|
|
5837
|
+
await harness?.stop().catch(() => undefined);
|
|
5838
|
+
redeemSpy.mockRestore();
|
|
5839
|
+
renameSpy.mockRestore();
|
|
5840
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5841
|
+
}
|
|
5842
|
+
});
|
|
5843
|
+
|
|
5844
|
+
test("SDK-only queued image promotion retains quota through shutdown until exact-run settlement proof", async () => {
|
|
5845
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-queued-adoption-"));
|
|
5846
|
+
const proofAttempted = Promise.withResolvers<void>();
|
|
5847
|
+
const released = Promise.withResolvers<void>();
|
|
5848
|
+
const promotionReady = Promise.withResolvers<void>();
|
|
5849
|
+
let activeHandle: string | undefined;
|
|
5850
|
+
const settlement = Promise.withResolvers<{ status: "settled" }>();
|
|
5851
|
+
let releases = 0;
|
|
5852
|
+
let promoted: PreflightHooks["onQueuedPromoted"];
|
|
5853
|
+
const proofHandles: string[] = [];
|
|
5854
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
5855
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5856
|
+
this: PromptImageUploadStore,
|
|
5857
|
+
owner,
|
|
5858
|
+
ids,
|
|
5859
|
+
) {
|
|
5860
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
5861
|
+
return {
|
|
5862
|
+
images: reservation.images,
|
|
5863
|
+
release: () => {
|
|
5864
|
+
releases++;
|
|
5865
|
+
reservation.release();
|
|
5866
|
+
released.resolve();
|
|
5867
|
+
},
|
|
5868
|
+
};
|
|
5869
|
+
});
|
|
5870
|
+
let harness: InvocationHarness | undefined;
|
|
5871
|
+
try {
|
|
5872
|
+
harness = await invocationHarness("queued-image-adoption", cwd, {
|
|
5873
|
+
connectionLiveness: true,
|
|
5874
|
+
isIdle: () => false,
|
|
5875
|
+
terminalAbortSeams: {
|
|
5876
|
+
getActivePromptHandle: () => activeHandle,
|
|
5877
|
+
waitForRunSettlement: handle => {
|
|
5878
|
+
proofHandles.push(handle);
|
|
5879
|
+
proofAttempted.resolve();
|
|
5880
|
+
return settlement.promise;
|
|
5881
|
+
},
|
|
5882
|
+
},
|
|
5883
|
+
sendUserMessage: async (content, options) => {
|
|
5884
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5885
|
+
if (!Array.isArray(content) || !content.some(block => block.type === "image"))
|
|
5886
|
+
throw new Error("Expected the queued submission to retain its staged image.");
|
|
5887
|
+
options?.onDispatchDisposition?.({ startsOwnRun: false });
|
|
5888
|
+
promoted = options?.onQueuedPromoted;
|
|
5889
|
+
promotionReady.resolve();
|
|
5890
|
+
},
|
|
5891
|
+
});
|
|
5892
|
+
harness.connect("client");
|
|
5893
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5894
|
+
expect(
|
|
5895
|
+
await harness.control("turn.prompt", {
|
|
5896
|
+
text: "queue image work for a later own-run promotion",
|
|
5897
|
+
stagedImages: [{ id }],
|
|
5898
|
+
}),
|
|
5899
|
+
).toMatchObject({ ok: true, result: { accepted: true } });
|
|
5900
|
+
await promotionReady.promise;
|
|
5901
|
+
expect(promoted).toBeDefined();
|
|
5902
|
+
promoted?.({ startsOwnRun: true });
|
|
5903
|
+
await harness.stop();
|
|
5904
|
+
expect(releases).toBe(0);
|
|
5905
|
+
|
|
5906
|
+
activeHandle = "queued-image-run";
|
|
5907
|
+
await harness.emit("agent_start");
|
|
5908
|
+
await proofAttempted.promise;
|
|
5909
|
+
expect(proofHandles).toEqual(["queued-image-run"]);
|
|
5910
|
+
expect(releases).toBe(0);
|
|
5911
|
+
|
|
5912
|
+
await harness.stop();
|
|
5913
|
+
expect(proofHandles).toEqual(["queued-image-run"]);
|
|
5914
|
+
settlement.resolve({ status: "settled" });
|
|
5915
|
+
await released.promise;
|
|
5916
|
+
expect(releases).toBe(1);
|
|
5917
|
+
} finally {
|
|
5918
|
+
settlement.resolve({ status: "settled" });
|
|
5919
|
+
await harness?.stop().catch(() => undefined);
|
|
5920
|
+
redeemSpy.mockRestore();
|
|
5921
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5922
|
+
}
|
|
5923
|
+
});
|
|
5924
|
+
|
|
5925
|
+
test.each([
|
|
5926
|
+
"pending",
|
|
5927
|
+
"slow-pending",
|
|
5928
|
+
"shutdown-slow-pending",
|
|
5929
|
+
"quarantined",
|
|
5930
|
+
"held-start-pending",
|
|
5931
|
+
"held-start-quarantined",
|
|
5932
|
+
"held-start-missing-handle",
|
|
5933
|
+
] as const)("SDK-only forced image terminals retain unsettled producer capacity (%s)", async mode => {
|
|
5934
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-forced-"));
|
|
5935
|
+
const sourceBytesBefore = getPromptImageProcessBudgetSnapshot().sourceBytes;
|
|
5936
|
+
const ledger = createRunResourceLedger();
|
|
5937
|
+
ledger.open("image-run");
|
|
5938
|
+
const tool = Promise.withResolvers<void>();
|
|
5939
|
+
const run = Promise.withResolvers<void>();
|
|
5940
|
+
const released = Promise.withResolvers<void>();
|
|
5941
|
+
const startEntered = Promise.withResolvers<void>();
|
|
5942
|
+
const releaseStart = Promise.withResolvers<void>();
|
|
5943
|
+
const holdStart = mode.startsWith("held-start");
|
|
5944
|
+
const proofAttempted = Promise.withResolvers<void>();
|
|
5945
|
+
const proofHandles: string[] = [];
|
|
5946
|
+
let drainTimeouts = 0;
|
|
5947
|
+
ledger.track("image-run", "tool", "abort-ignoring-tool", tool.promise);
|
|
5948
|
+
let releases = 0;
|
|
5949
|
+
const cleanupReservations: Array<() => void> = [];
|
|
5950
|
+
const original = PromptImageUploadStore.prototype.redeem;
|
|
5951
|
+
const spy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
5952
|
+
this: PromptImageUploadStore,
|
|
5953
|
+
owner,
|
|
5954
|
+
ids,
|
|
5955
|
+
) {
|
|
5956
|
+
const reservation = original.call(this, owner, ids);
|
|
5957
|
+
cleanupReservations.push(reservation.release);
|
|
5958
|
+
return {
|
|
5959
|
+
images: reservation.images,
|
|
5960
|
+
release: () => {
|
|
5961
|
+
releases++;
|
|
5962
|
+
reservation.release();
|
|
5963
|
+
released.resolve();
|
|
5964
|
+
},
|
|
5965
|
+
};
|
|
5966
|
+
});
|
|
5967
|
+
let harness: InvocationHarness | undefined;
|
|
5968
|
+
try {
|
|
5969
|
+
harness = await invocationHarness(`image-forced-${mode}`, cwd, {
|
|
5970
|
+
connectionLiveness: true,
|
|
5971
|
+
...(holdStart
|
|
5972
|
+
? {
|
|
5973
|
+
persistInterceptor: () => {},
|
|
5974
|
+
persistHold: { type: "agent_start", onEntered: startEntered.resolve, release: releaseStart.promise },
|
|
5975
|
+
onLifecycleDrainTimeout: () => {
|
|
5976
|
+
drainTimeouts++;
|
|
5977
|
+
},
|
|
5978
|
+
}
|
|
5979
|
+
: {}),
|
|
5980
|
+
terminalAbortSeams: {
|
|
5981
|
+
getActivePromptHandle: () => (mode.endsWith("missing-handle") ? undefined : "image-run"),
|
|
5982
|
+
waitForRunSettlement: handle => {
|
|
5983
|
+
proofHandles.push(handle);
|
|
5984
|
+
proofAttempted.resolve();
|
|
5985
|
+
return ledger.waitForSettlement(handle, { graceMs: null });
|
|
5986
|
+
},
|
|
5987
|
+
},
|
|
5988
|
+
sendUserMessage: async (_content, options) => {
|
|
5989
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5990
|
+
await run.promise;
|
|
5991
|
+
},
|
|
5992
|
+
});
|
|
5993
|
+
harness.connect("client");
|
|
5994
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
5995
|
+
expect(
|
|
5996
|
+
await harness.control("turn.prompt", { text: "hold the exact producer", stagedImages: [{ id }] }),
|
|
5997
|
+
).toMatchObject({ ok: true });
|
|
5998
|
+
await harness.emit("agent_start");
|
|
5999
|
+
if (holdStart) await startEntered.promise;
|
|
6000
|
+
harness.disconnect("client");
|
|
6001
|
+
ledger.seal("image-run");
|
|
6002
|
+
if (mode.endsWith("quarantined")) ledger.quarantine("image-run");
|
|
6003
|
+
if (holdStart) {
|
|
6004
|
+
await harness.stop();
|
|
6005
|
+
expect(drainTimeouts).toBe(1);
|
|
6006
|
+
expect(releases).toBe(0);
|
|
6007
|
+
releaseStart.resolve();
|
|
6008
|
+
} else await harness.emit("agent_end", { stopReason: "cancelled" });
|
|
6009
|
+
if (mode.includes("slow")) {
|
|
6010
|
+
await proofAttempted.promise;
|
|
6011
|
+
const chargedBytes = getPromptImageProcessBudgetSnapshot().sourceBytes;
|
|
6012
|
+
expect(chargedBytes).toBeGreaterThan(sourceBytesBefore);
|
|
6013
|
+
await harness.emit("agent_end", { stopReason: "cancelled" });
|
|
6014
|
+
if (mode.startsWith("shutdown")) {
|
|
6015
|
+
run.resolve();
|
|
6016
|
+
await harness.stop();
|
|
6017
|
+
await harness.stop();
|
|
6018
|
+
}
|
|
6019
|
+
// Exceed the retired helper's entire 100 x 100 ms polling window.
|
|
6020
|
+
await Bun.sleep(10_100);
|
|
6021
|
+
expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBe(chargedBytes);
|
|
6022
|
+
expect(proofHandles).toEqual(["image-run"]);
|
|
6023
|
+
} else await Bun.sleep(150);
|
|
6024
|
+
expect(releases).toBe(0);
|
|
6025
|
+
tool.resolve();
|
|
6026
|
+
if (mode.endsWith("pending")) {
|
|
6027
|
+
await released.promise;
|
|
6028
|
+
expect(releases).toBe(1);
|
|
6029
|
+
if (mode.includes("slow")) {
|
|
6030
|
+
expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBe(sourceBytesBefore);
|
|
6031
|
+
await harness.stop();
|
|
6032
|
+
expect(proofHandles).toEqual(["image-run"]);
|
|
6033
|
+
expect(releases).toBe(1);
|
|
6034
|
+
}
|
|
6035
|
+
} else {
|
|
6036
|
+
await harness.stop();
|
|
6037
|
+
await Bun.sleep(150);
|
|
6038
|
+
expect(releases).toBe(0);
|
|
6039
|
+
expect(await ledger.waitForSettlement("image-run", { graceMs: 0 })).toMatchObject(
|
|
6040
|
+
mode.endsWith("quarantined") ? { status: "unfenced", reason: "quarantined" } : { status: "settled" },
|
|
6041
|
+
);
|
|
6042
|
+
}
|
|
6043
|
+
} finally {
|
|
6044
|
+
releaseStart.resolve();
|
|
6045
|
+
tool.resolve();
|
|
6046
|
+
run.resolve();
|
|
6047
|
+
try {
|
|
6048
|
+
await harness?.stop();
|
|
6049
|
+
} finally {
|
|
6050
|
+
spy.mockRestore();
|
|
6051
|
+
for (const release of cleanupReservations) release();
|
|
6052
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6053
|
+
}
|
|
6054
|
+
}
|
|
6055
|
+
});
|
|
6056
|
+
|
|
6057
|
+
test("SDK-only failed queued cancellation retains the predecessor image cleanup owner for retry", async () => {
|
|
6058
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-cleanup-retry-"));
|
|
6059
|
+
let failTerminal = true;
|
|
6060
|
+
let deadlineMs = 60_000;
|
|
6061
|
+
let releases = 0;
|
|
6062
|
+
let queued: { commandId?: unknown; turnId?: unknown } | undefined;
|
|
6063
|
+
const producer = Promise.withResolvers<void>();
|
|
6064
|
+
const released = Promise.withResolvers<void>();
|
|
6065
|
+
const repaired = Promise.withResolvers<void>();
|
|
6066
|
+
const original = PromptImageUploadStore.prototype.redeem;
|
|
6067
|
+
const spy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
6068
|
+
this: PromptImageUploadStore,
|
|
6069
|
+
owner,
|
|
6070
|
+
ids,
|
|
6071
|
+
) {
|
|
6072
|
+
const reservation = original.call(this, owner, ids);
|
|
6073
|
+
return {
|
|
6074
|
+
images: reservation.images,
|
|
6075
|
+
release: () => {
|
|
6076
|
+
releases++;
|
|
6077
|
+
reservation.release();
|
|
6078
|
+
released.resolve();
|
|
6079
|
+
},
|
|
6080
|
+
};
|
|
6081
|
+
});
|
|
6082
|
+
let harness: InvocationHarness | undefined;
|
|
6083
|
+
try {
|
|
6084
|
+
harness = await invocationHarness("image-cleanup-retry", cwd, {
|
|
6085
|
+
connectionLiveness: true,
|
|
6086
|
+
settings: {
|
|
6087
|
+
get: (key: string) =>
|
|
6088
|
+
key === "sdk.promptDeadlineMs" ? deadlineMs : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
6089
|
+
} as unknown as Settings,
|
|
6090
|
+
terminalAbortSeams: {
|
|
6091
|
+
getActivePromptHandle: () => undefined,
|
|
6092
|
+
},
|
|
6093
|
+
persistInterceptor: transition => {
|
|
6094
|
+
if (failTerminal && transition.type === "agent_end") throw new Error("held queue terminal persistence");
|
|
6095
|
+
},
|
|
6096
|
+
onDurableAttempt: attempt => {
|
|
6097
|
+
if (
|
|
6098
|
+
!failTerminal &&
|
|
6099
|
+
attempt.type === "agent_end" &&
|
|
6100
|
+
attempt.outcome === "committed" &&
|
|
6101
|
+
attempt.terminalAt !== undefined &&
|
|
6102
|
+
attempt.commandId === queued?.commandId &&
|
|
6103
|
+
attempt.turnId === queued?.turnId
|
|
6104
|
+
)
|
|
6105
|
+
repaired.resolve();
|
|
6106
|
+
},
|
|
6107
|
+
sendUserMessage: async (content, options) => {
|
|
6108
|
+
await options?.onPreflightAcceptCommit?.();
|
|
6109
|
+
if (Array.isArray(content)) {
|
|
6110
|
+
options?.onDispatchDisposition?.({ startsOwnRun: true });
|
|
6111
|
+
await producer.promise;
|
|
6112
|
+
}
|
|
6113
|
+
options?.onDispatchDisposition?.({ startsOwnRun: false });
|
|
6114
|
+
options?.preflightSignal?.addEventListener("abort", () => options.onQueuedPromoted?.({ removed: true }), {
|
|
6115
|
+
once: true,
|
|
6116
|
+
});
|
|
6117
|
+
},
|
|
6118
|
+
});
|
|
6119
|
+
harness.connect("client");
|
|
6120
|
+
const id = await stageRuntimeImage(harness, makeTestPng());
|
|
6121
|
+
expect(
|
|
6122
|
+
await harness.control("turn.prompt", { text: "retain predecessor image", stagedImages: [{ id }] }),
|
|
6123
|
+
).toMatchObject({ ok: true });
|
|
6124
|
+
deadlineMs = 100;
|
|
6125
|
+
const followUp = await harness.control("turn.follow_up", { text: "queue a removal with failed publication" });
|
|
6126
|
+
expect(followUp).toMatchObject({ ok: true });
|
|
6127
|
+
queued = followUp.result;
|
|
6128
|
+
await expect(harness.stop()).rejects.toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
|
|
6129
|
+
expect(releases).toBe(0);
|
|
6130
|
+
failTerminal = false;
|
|
6131
|
+
await repaired.promise;
|
|
6132
|
+
expect(releases).toBe(0);
|
|
6133
|
+
// Either the original removal publisher or deadline recovery can retire
|
|
6134
|
+
// this cancellation. Until then shutdown must still fail closed.
|
|
6135
|
+
const retryUntil = Date.now() + 5_000;
|
|
6136
|
+
for (;;) {
|
|
6137
|
+
try {
|
|
6138
|
+
await harness.stop();
|
|
6139
|
+
break;
|
|
6140
|
+
} catch (error) {
|
|
6141
|
+
expect(error).toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
|
|
6142
|
+
expect(releases).toBe(0);
|
|
6143
|
+
if (Date.now() >= retryUntil) throw error;
|
|
6144
|
+
await Bun.sleep(10);
|
|
6145
|
+
}
|
|
6146
|
+
}
|
|
6147
|
+
expect(releases).toBe(0);
|
|
6148
|
+
await harness.switchSession("image-cleanup-successor");
|
|
6149
|
+
expect(releases).toBe(0);
|
|
6150
|
+
producer.resolve();
|
|
6151
|
+
await released.promise;
|
|
6152
|
+
expect(releases).toBe(1);
|
|
6153
|
+
} finally {
|
|
6154
|
+
failTerminal = false;
|
|
6155
|
+
producer.resolve();
|
|
6156
|
+
try {
|
|
6157
|
+
await harness?.stop();
|
|
6158
|
+
} finally {
|
|
6159
|
+
spy.mockRestore();
|
|
6160
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
6163
|
+
});
|
|
6164
|
+
|
|
6165
|
+
test("SDK-only shutdown retains staged-image quota when runtime stop fails and releases it after retry", async () => {
|
|
6166
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-staged-image-shutdown-"));
|
|
6167
|
+
const bytes = makeTestPng();
|
|
6168
|
+
const releases: string[] = [];
|
|
6169
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
6170
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
6171
|
+
this: PromptImageUploadStore,
|
|
6172
|
+
owner,
|
|
6173
|
+
ids,
|
|
6174
|
+
) {
|
|
6175
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
6176
|
+
return {
|
|
6177
|
+
images: reservation.images,
|
|
6178
|
+
release: () => {
|
|
6179
|
+
releases.push("released");
|
|
6180
|
+
reservation.release();
|
|
6181
|
+
},
|
|
6182
|
+
};
|
|
6183
|
+
});
|
|
6184
|
+
let harness: InvocationHarness | undefined;
|
|
6185
|
+
let stopSpy: { mockRestore(): void } | undefined;
|
|
6186
|
+
try {
|
|
6187
|
+
harness = await invocationHarness("staged-image-shutdown", cwd, {
|
|
6188
|
+
connectionLiveness: true,
|
|
6189
|
+
sendUserMessage: async (_content, options) => {
|
|
6190
|
+
await options?.onPreflightAcceptCommit?.();
|
|
6191
|
+
options?.onDispatchDisposition?.({ startsOwnRun: true });
|
|
6192
|
+
await neverSettlingPromise();
|
|
6193
|
+
},
|
|
6194
|
+
});
|
|
6195
|
+
harness.connect("client");
|
|
6196
|
+
const id = await stageRuntimeImage(harness, bytes);
|
|
6197
|
+
const accepted = await harness.control("turn.prompt", {
|
|
6198
|
+
text: "retain through failed stop",
|
|
6199
|
+
stagedImages: [{ id }],
|
|
6200
|
+
});
|
|
6201
|
+
expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
|
|
6202
|
+
await harness.emit("agent_start");
|
|
6203
|
+
expect(releases).toEqual([]);
|
|
6204
|
+
|
|
6205
|
+
stopSpy = spyOn(SessionSdkSessionRuntime.prototype, "stop").mockImplementationOnce(async () => {
|
|
6206
|
+
throw new Error("injected transport stop failure");
|
|
6207
|
+
});
|
|
6208
|
+
await expect(harness.stop()).rejects.toThrow("injected transport stop failure");
|
|
6209
|
+
expect(releases).toEqual([]);
|
|
6210
|
+
|
|
6211
|
+
stopSpy.mockRestore();
|
|
6212
|
+
stopSpy = undefined;
|
|
6213
|
+
await harness.stop();
|
|
6214
|
+
expect(releases).toEqual(["released"]);
|
|
6215
|
+
} finally {
|
|
6216
|
+
stopSpy?.mockRestore();
|
|
6217
|
+
await harness?.stop().catch(() => undefined);
|
|
6218
|
+
redeemSpy.mockRestore();
|
|
6219
|
+
await rm(cwd, { recursive: true, force: true });
|
|
6220
|
+
}
|
|
6221
|
+
});
|
|
6222
|
+
|
|
4789
6223
|
test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => {
|
|
4790
6224
|
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`));
|
|
4791
6225
|
let harness: InvocationHarness | undefined;
|
|
@@ -5296,6 +6730,7 @@ function createInterceptorReconciliationStore(
|
|
|
5296
6730
|
type: string | undefined;
|
|
5297
6731
|
commandId?: string;
|
|
5298
6732
|
turnId?: string;
|
|
6733
|
+
terminalAt?: number;
|
|
5299
6734
|
outcome: "rejected" | "committed";
|
|
5300
6735
|
}) => void,
|
|
5301
6736
|
): SdkOnlyReconciliationStore {
|
|
@@ -5310,6 +6745,7 @@ function createInterceptorReconciliationStore(
|
|
|
5310
6745
|
let transitionType: string | undefined;
|
|
5311
6746
|
for (const record of records as Array<{
|
|
5312
6747
|
status?: string;
|
|
6748
|
+
startedAt?: number;
|
|
5313
6749
|
terminalAt?: number;
|
|
5314
6750
|
commandId?: string;
|
|
5315
6751
|
pendingOutcome?: unknown;
|
|
@@ -5338,6 +6774,7 @@ function createInterceptorReconciliationStore(
|
|
|
5338
6774
|
throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
|
|
5339
6775
|
}
|
|
5340
6776
|
const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
|
|
6777
|
+
if (record.startedAt !== undefined && previous?.startedAt === undefined) transitionType ??= "agent_start";
|
|
5341
6778
|
const previousPendingOutcome = (
|
|
5342
6779
|
previous as
|
|
5343
6780
|
| (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
|
|
@@ -5367,11 +6804,12 @@ function createInterceptorReconciliationStore(
|
|
|
5367
6804
|
hold.onEntered();
|
|
5368
6805
|
await hold.release;
|
|
5369
6806
|
}
|
|
5370
|
-
for (const record of records as Array<{ commandId?: string; turnId?: string }>)
|
|
6807
|
+
for (const record of records as Array<{ commandId?: string; turnId?: string; terminalAt?: number }>)
|
|
5371
6808
|
onDurableAttempt?.({
|
|
5372
6809
|
type: transitionType,
|
|
5373
6810
|
commandId: record?.commandId,
|
|
5374
6811
|
turnId: record?.turnId,
|
|
6812
|
+
terminalAt: record?.terminalAt,
|
|
5375
6813
|
outcome: "committed",
|
|
5376
6814
|
});
|
|
5377
6815
|
backing.records = records;
|
|
@@ -5508,7 +6946,7 @@ function realSendUserMessage(session: AgentSession) {
|
|
|
5508
6946
|
return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
|
|
5509
6947
|
await options?.onPreflightAcceptCommit?.();
|
|
5510
6948
|
const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
|
|
5511
|
-
await session.sendUserMessage(content as string, dispatchOptions as
|
|
6949
|
+
await session.sendUserMessage(content as string, dispatchOptions as SendUserMessageOptions);
|
|
5512
6950
|
};
|
|
5513
6951
|
}
|
|
5514
6952
|
|
|
@@ -8540,8 +9978,25 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8540
9978
|
}
|
|
8541
9979
|
});
|
|
8542
9980
|
|
|
8543
|
-
test("a captured cancelled end
|
|
9981
|
+
test("a captured cancelled end retains staged-image quota until confirmed tool recovery", async () => {
|
|
8544
9982
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-captured-uncertain-"));
|
|
9983
|
+
const bytes = makeTestPng();
|
|
9984
|
+
const releaseCalls: string[] = [];
|
|
9985
|
+
const originalRedeem = PromptImageUploadStore.prototype.redeem;
|
|
9986
|
+
const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
|
|
9987
|
+
this: PromptImageUploadStore,
|
|
9988
|
+
owner,
|
|
9989
|
+
ids,
|
|
9990
|
+
) {
|
|
9991
|
+
const reservation = originalRedeem.call(this, owner, ids);
|
|
9992
|
+
return {
|
|
9993
|
+
images: reservation.images,
|
|
9994
|
+
release: () => {
|
|
9995
|
+
releaseCalls.push("released");
|
|
9996
|
+
reservation.release();
|
|
9997
|
+
},
|
|
9998
|
+
};
|
|
9999
|
+
});
|
|
8545
10000
|
const sessionId = "deadline-captured-uncertain";
|
|
8546
10001
|
const sessionFile = path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`);
|
|
8547
10002
|
await Bun.write(sessionFile, "");
|
|
@@ -8564,15 +10019,18 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8564
10019
|
const persistenceLog: Array<PersistenceInstrumentationEvent & { label: string }> = [];
|
|
8565
10020
|
let boundaryWaitStarted = false;
|
|
8566
10021
|
let abortCalls = 0;
|
|
10022
|
+
let sends = 0;
|
|
8567
10023
|
let harness: InvocationHarness | undefined;
|
|
8568
10024
|
try {
|
|
8569
10025
|
harness = await invocationHarness(sessionId, cwd, {
|
|
10026
|
+
connectionLiveness: true,
|
|
8570
10027
|
settings: {
|
|
8571
10028
|
// This test covers deferred terminal recovery, not deadline-triggered worktree persistence.
|
|
8572
10029
|
get: (key: string) =>
|
|
8573
10030
|
key === "sdk.promptDeadlineMs" ? 150 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
8574
10031
|
} as unknown as Settings,
|
|
8575
10032
|
sendUserMessage: async (_content, options) => {
|
|
10033
|
+
sends++;
|
|
8576
10034
|
await options?.onPreflightAcceptCommit?.();
|
|
8577
10035
|
await neverSettlingPromise();
|
|
8578
10036
|
},
|
|
@@ -8601,7 +10059,13 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8601
10059
|
},
|
|
8602
10060
|
},
|
|
8603
10061
|
});
|
|
8604
|
-
|
|
10062
|
+
harness.connect("client");
|
|
10063
|
+
const id = await stageRuntimeImage(harness, bytes);
|
|
10064
|
+
const accepted = await harness.control("turn.prompt", {
|
|
10065
|
+
text: "capture uncertain end",
|
|
10066
|
+
clientRef: "captured-uncertain-image",
|
|
10067
|
+
stagedImages: [{ id }],
|
|
10068
|
+
});
|
|
8605
10069
|
expect(accepted.ok).toBe(true);
|
|
8606
10070
|
const commandId = accepted.result?.commandId;
|
|
8607
10071
|
const turnId = accepted.result?.turnId;
|
|
@@ -8620,6 +10084,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8620
10084
|
await harness.emit("agent_end", { stopReason: "cancelled" });
|
|
8621
10085
|
await Bun.sleep(5_200);
|
|
8622
10086
|
expect(abortCalls).toBe(0);
|
|
10087
|
+
expect(releaseCalls).toEqual([]);
|
|
8623
10088
|
expect((await harness.query("turn.prompt_status", correlation)).result).toMatchObject({ status: "in_flight" });
|
|
8624
10089
|
expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toEqual([]);
|
|
8625
10090
|
expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toEqual([]);
|
|
@@ -8631,6 +10096,17 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8631
10096
|
deadlineRecoveryPending: true,
|
|
8632
10097
|
pendingOutcome: { kind: "stopped", reason: "cancelled" },
|
|
8633
10098
|
});
|
|
10099
|
+
// An uncertain retained outcome is reconciliation-only, never replayable.
|
|
10100
|
+
expect(
|
|
10101
|
+
await harness.control("turn.prompt", {
|
|
10102
|
+
text: "capture uncertain end",
|
|
10103
|
+
clientRef: "captured-uncertain-image",
|
|
10104
|
+
stagedImages: [{ id }],
|
|
10105
|
+
}),
|
|
10106
|
+
).toMatchObject({ ok: false, error: { code: "client_ref_conflict" } });
|
|
10107
|
+
expect(redeemSpy.mock.calls).toHaveLength(1);
|
|
10108
|
+
expect(sends).toBe(1);
|
|
10109
|
+
expect(releaseCalls).toEqual([]);
|
|
8634
10110
|
const reopened = createInvocationReconciliation({
|
|
8635
10111
|
store: createReconciliationStore({ sessionFile, sessionId }),
|
|
8636
10112
|
});
|
|
@@ -8679,8 +10155,10 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8679
10155
|
outcome: { kind: "stopped", reason: "cancelled" },
|
|
8680
10156
|
});
|
|
8681
10157
|
expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
|
|
10158
|
+
expect(releaseCalls).toEqual(["released"]);
|
|
8682
10159
|
} finally {
|
|
8683
10160
|
await harness?.stop();
|
|
10161
|
+
redeemSpy.mockRestore();
|
|
8684
10162
|
await Bun.sleep(10);
|
|
8685
10163
|
await rm(cwd, { recursive: true, force: true });
|
|
8686
10164
|
}
|
|
@@ -8808,7 +10286,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8808
10286
|
toolName: "bash",
|
|
8809
10287
|
args: {},
|
|
8810
10288
|
});
|
|
8811
|
-
|
|
10289
|
+
// This checks ordering, not prompt latency; allow the scheduler to reach
|
|
10290
|
+
// the boundary when many CI shards are contending for the event loop.
|
|
10291
|
+
const boundaryDeadline = Date.now() + 10_000;
|
|
8812
10292
|
while (!boundaryWaitStarted && Date.now() < boundaryDeadline) await Bun.sleep(10);
|
|
8813
10293
|
expect(boundaryWaitStarted).toBe(true);
|
|
8814
10294
|
expect(abortCalls).toHaveLength(0);
|
|
@@ -8822,7 +10302,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8822
10302
|
});
|
|
8823
10303
|
await Bun.sleep(30);
|
|
8824
10304
|
expect(abortCalls).toHaveLength(0);
|
|
8825
|
-
const abortDeadline = Date.now() +
|
|
10305
|
+
const abortDeadline = Date.now() + 10_000;
|
|
8826
10306
|
while (abortCalls.length === 0 && Date.now() < abortDeadline) await Bun.sleep(10);
|
|
8827
10307
|
expect(abortCalls).toEqual([{ handle: "deadline-tool-run", scope: "owned", expectedEpoch: epoch }]);
|
|
8828
10308
|
expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
|
|
@@ -9126,6 +10606,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9126
10606
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-start-race-"));
|
|
9127
10607
|
const claimEntered = Promise.withResolvers<void>();
|
|
9128
10608
|
const releaseClaim = Promise.withResolvers<void>();
|
|
10609
|
+
const persistenceEvents: PersistenceInstrumentationEvent[] = [];
|
|
10610
|
+
const durableAttempts: string[] = [];
|
|
9129
10611
|
const epoch = 83;
|
|
9130
10612
|
let harness: InvocationHarness | undefined;
|
|
9131
10613
|
let startPromise: Promise<void> | undefined;
|
|
@@ -9141,6 +10623,20 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9141
10623
|
},
|
|
9142
10624
|
persistInterceptor: () => {},
|
|
9143
10625
|
agentFailedWriteFailures: 0,
|
|
10626
|
+
onPersistenceInstrumentation: event => {
|
|
10627
|
+
if (persistenceEvents.length < 64)
|
|
10628
|
+
persistenceEvents.push({
|
|
10629
|
+
...event,
|
|
10630
|
+
error: event.error?.slice(0, 256),
|
|
10631
|
+
detail: event.detail?.slice(0, 256),
|
|
10632
|
+
});
|
|
10633
|
+
},
|
|
10634
|
+
onDurableAttempt: attempt => {
|
|
10635
|
+
if (durableAttempts.length < 64)
|
|
10636
|
+
durableAttempts.push(
|
|
10637
|
+
`${attempt.type ?? "unclassified"}:${attempt.outcome}:${attempt.commandId ?? ""}:${attempt.turnId ?? ""}:${attempt.terminalAt ?? ""}`,
|
|
10638
|
+
);
|
|
10639
|
+
},
|
|
9144
10640
|
persistHolds: [
|
|
9145
10641
|
{
|
|
9146
10642
|
type: "deadline_claim",
|
|
@@ -9172,12 +10668,30 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9172
10668
|
const accepted = await harness.control("turn.prompt", { text: "start while expiring" });
|
|
9173
10669
|
expect(accepted.ok).toBe(true);
|
|
9174
10670
|
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
9175
|
-
const
|
|
10671
|
+
const claimWaitStartedAt = Date.now();
|
|
10672
|
+
const claimDeadline = claimWaitStartedAt + 10_000;
|
|
9176
10673
|
while (!claimWasEntered && Date.now() < claimDeadline) await Bun.sleep(5);
|
|
9177
|
-
|
|
10674
|
+
try {
|
|
10675
|
+
expect(claimWasEntered).toBe(true);
|
|
10676
|
+
} catch (error) {
|
|
10677
|
+
throw new Error(
|
|
10678
|
+
`${error instanceof Error ? error.message : String(error)}; claim_setup=${JSON.stringify({
|
|
10679
|
+
accepted,
|
|
10680
|
+
correlation,
|
|
10681
|
+
elapsedMs: Date.now() - claimWaitStartedAt,
|
|
10682
|
+
claimWasEntered,
|
|
10683
|
+
handleReads,
|
|
10684
|
+
abortCalls,
|
|
10685
|
+
persistenceEvents,
|
|
10686
|
+
durableAttempts,
|
|
10687
|
+
broadcasts: harness.broadcasts.slice(-16),
|
|
10688
|
+
})}`,
|
|
10689
|
+
{ cause: error },
|
|
10690
|
+
);
|
|
10691
|
+
}
|
|
9178
10692
|
await claimEntered.promise;
|
|
9179
10693
|
startPromise = harness.emit("agent_start");
|
|
9180
|
-
const captureDeadline = Date.now() +
|
|
10694
|
+
const captureDeadline = Date.now() + 10_000;
|
|
9181
10695
|
while (handleReads === 0 && Date.now() < captureDeadline) await Bun.sleep(5);
|
|
9182
10696
|
expect(handleReads).toBeGreaterThan(0);
|
|
9183
10697
|
releaseClaim.resolve();
|
|
@@ -9196,7 +10710,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9196
10710
|
await harness?.stop();
|
|
9197
10711
|
await rm(cwd, { recursive: true, force: true });
|
|
9198
10712
|
}
|
|
9199
|
-
});
|
|
10713
|
+
}, 15_000);
|
|
9200
10714
|
|
|
9201
10715
|
test("agent_start during an expiry claim keeps the queued correlation owned", async () => {
|
|
9202
10716
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-start-race-"));
|
|
@@ -9209,8 +10723,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9209
10723
|
let claimWasEntered = false;
|
|
9210
10724
|
let handleReads = 0;
|
|
9211
10725
|
let boundaryWaitStarted = false;
|
|
10726
|
+
let toolDrainWasObserved = false;
|
|
9212
10727
|
let abortCalls = 0;
|
|
9213
|
-
let terminalEventPromise: Promise<void> | undefined;
|
|
9214
10728
|
try {
|
|
9215
10729
|
harness = await invocationHarness("deadline-start-race", cwd, {
|
|
9216
10730
|
settings: zeroProgressSettings,
|
|
@@ -9238,8 +10752,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9238
10752
|
},
|
|
9239
10753
|
pendingToolExecutions: () => {
|
|
9240
10754
|
if (activeTools.size > 0) boundaryWaitStarted = true;
|
|
9241
|
-
else if (boundaryWaitStarted
|
|
9242
|
-
terminalEventPromise = harness?.emit("agent_end", { stopReason: "cancelled" });
|
|
10755
|
+
else if (boundaryWaitStarted) toolDrainWasObserved = true;
|
|
9243
10756
|
return [...activeTools];
|
|
9244
10757
|
},
|
|
9245
10758
|
abortPromptAndWaitWithTerminal: async () => {
|
|
@@ -9264,15 +10777,14 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9264
10777
|
while (!boundaryWaitStarted && Date.now() < boundaryDeadline) await Bun.sleep(5);
|
|
9265
10778
|
expect(boundaryWaitStarted).toBe(true);
|
|
9266
10779
|
expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_start")).toHaveLength(1);
|
|
10780
|
+
// The producer emits its real end after tool settlement; the ledger observer stays read-only.
|
|
9267
10781
|
activeTools.clear();
|
|
9268
|
-
|
|
9269
|
-
while (terminalEventPromise === undefined && Date.now() < terminalEventDeadline) await Bun.sleep(5);
|
|
9270
|
-
expect(terminalEventPromise).toBeDefined();
|
|
9271
|
-
await terminalEventPromise;
|
|
10782
|
+
await harness.emit("agent_end", { stopReason: "cancelled" });
|
|
9272
10783
|
expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
|
|
9273
10784
|
status: "terminal_ok",
|
|
9274
10785
|
outcome: { kind: "stopped", reason: "cancelled" },
|
|
9275
10786
|
});
|
|
10787
|
+
expect(toolDrainWasObserved).toBe(true);
|
|
9276
10788
|
expect(abortCalls).toBeLessThanOrEqual(1);
|
|
9277
10789
|
expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
|
|
9278
10790
|
expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
|
|
@@ -9281,7 +10793,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
9281
10793
|
await harness?.stop();
|
|
9282
10794
|
await rm(cwd, { recursive: true, force: true });
|
|
9283
10795
|
}
|
|
9284
|
-
|
|
10796
|
+
// Real timers plus a reconciliation claim exceed the default 5s on a loaded shard.
|
|
10797
|
+
}, 20_000);
|
|
9285
10798
|
|
|
9286
10799
|
test("a sibling publication failure does not hide the expiring correlation's end", async () => {
|
|
9287
10800
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-sibling-publication-"));
|