@gajae-code/coding-agent 0.16.7 → 0.17.1
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 +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -47,12 +47,13 @@ import {
|
|
|
47
47
|
acpMcpLaunchFailure,
|
|
48
48
|
} from "../../sdk/acp";
|
|
49
49
|
import { resolveAcpFinalText } from "../../sdk/acp/final-text";
|
|
50
|
-
import {
|
|
50
|
+
import type { SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
|
|
51
51
|
import { ensureBroker } from "../../sdk/broker/ensure";
|
|
52
52
|
import { canonicalSessionCwd } from "../../sdk/broker/session-index";
|
|
53
53
|
import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
|
|
54
54
|
import type { AbortScope } from "../../sdk/host/control/operations";
|
|
55
55
|
import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
|
|
56
|
+
import { failedPromptOutcome, isSdkPromptFailurePhase, rephaseFailedOutcome } from "../../sdk/prompt-failure";
|
|
56
57
|
import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
|
|
57
58
|
import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
|
|
58
59
|
import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation";
|
|
@@ -101,16 +102,53 @@ const MAX_PROMPT_FRAME_BYTES = 256 * 1024;
|
|
|
101
102
|
* envelope. A canonical-length UUID keeps the measurement equal to the real frame.
|
|
102
103
|
*/
|
|
103
104
|
const PROMPT_FRAME_ID_PLACEHOLDER = "00000000-0000-4000-8000-000000000000";
|
|
105
|
+
/**
|
|
106
|
+
* Wall-clock ceiling an external ACP client grants a `session/new` before it
|
|
107
|
+
* abandons the connection (paseo's daemon uses 60s). gjc must return a
|
|
108
|
+
* `session.create` response — a launched session or a terminal launch error —
|
|
109
|
+
* inside this window, or the client reports a dead provider while gjc is still
|
|
110
|
+
* healthily spawning the host. See issue #5565.
|
|
111
|
+
*/
|
|
112
|
+
export const ACP_EXTERNAL_CONNECT_TIMEOUT_MS = 60_000;
|
|
104
113
|
/**
|
|
105
114
|
* Readiness budget every ACP session launch requests, independent of MCP.
|
|
106
115
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
116
|
+
* Two constraints bound this value:
|
|
117
|
+
*
|
|
118
|
+
* 1. Floor — host cold start costs the same whether or not the client declared
|
|
119
|
+
* MCP servers, so leaving the MCP-less path on the broker's 10s default made
|
|
120
|
+
* it the only ACP launch that failed under ordinary concurrency: a second
|
|
121
|
+
* host starting beside the first measurably crosses that deadline, the broker
|
|
122
|
+
* reports the launch as `terminal_uncertain`, and an external client surfaces
|
|
123
|
+
* a dead provider.
|
|
124
|
+
* 2. Ceiling — the broker may park a startup in its admission queue for a whole
|
|
125
|
+
* readiness budget before the readiness clock even starts, so the ACP adapter
|
|
126
|
+
* sizes its client-side wait at `lifecycleStartupBudgetMs(R)` = queue wait +
|
|
127
|
+
* readiness = `2·R`, plus caller slack. With R = 30.5s that wait reached ~62s
|
|
128
|
+
* — past paseo's 60s connect timeout — so a queued or slow-cold-start launch
|
|
129
|
+
* let paseo abandon the connection before gjc could answer `session/new` at
|
|
130
|
+
* all (#5565). R must keep `2·R + slack` safely under
|
|
131
|
+
* {@link ACP_EXTERNAL_CONNECT_TIMEOUT_MS}.
|
|
132
|
+
*
|
|
133
|
+
* 22s satisfies both: comfortably above the ~16s cold starts observed on slow
|
|
134
|
+
* hosts (and the 10s concurrency floor) while holding the client-side lifecycle
|
|
135
|
+
* budget near 45s — a ~15s margin under the 60s ceiling.
|
|
136
|
+
*/
|
|
137
|
+
export const ACP_SESSION_READINESS_TIMEOUT_MS = 22_000;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A freshly created session host can accept the first turn before it has finished
|
|
141
|
+
* coming up (provider stream, tool registry, MCP wiring), so the very first prompt
|
|
142
|
+
* can terminalize as `prompt_failed` — surfaced to an ACP client as an opaque
|
|
143
|
+
* `-32603` with no usable result (issue #5574). The failed turn produced no answer,
|
|
144
|
+
* so re-submitting the same first prompt a bounded number of times recovers the
|
|
145
|
+
* startup race instead of ending the session on its first turn. Only the first
|
|
146
|
+
* logical prompt of a session retries, and only an `agent_failed` `prompt_failed`
|
|
147
|
+
* terminal (never a deadline, a client cancel, or a clean stop) is retried.
|
|
112
148
|
*/
|
|
113
|
-
const
|
|
149
|
+
const ACP_FIRST_PROMPT_MAX_RETRIES = 2;
|
|
150
|
+
/** Backoff before each first-prompt retry, scaled by attempt (250ms, then 500ms). */
|
|
151
|
+
const ACP_FIRST_PROMPT_RETRY_BASE_DELAY_MS = 250;
|
|
114
152
|
|
|
115
153
|
type JsonObject = Record<string, unknown>;
|
|
116
154
|
interface PromptWaiter {
|
|
@@ -143,6 +181,10 @@ interface PromptWaiter {
|
|
|
143
181
|
cancelWatchdog?: () => void;
|
|
144
182
|
/** What the host is observably doing — a tool running, a model call unanswered — and the bound that follows from it. */
|
|
145
183
|
activity: PromptActivity;
|
|
184
|
+
/** True once a prompt-owned progress frame (agent_start / message / tool) was observed; gates first-turn retries. */
|
|
185
|
+
observedTurnActivity: boolean;
|
|
186
|
+
/** True once a prompt-owned tool execution frame was observed; vetoes a first-turn retry that would re-run it. */
|
|
187
|
+
observedToolExecution: boolean;
|
|
146
188
|
/** Coordinates a prompt-control rejection racing an acknowledged ACP cancellation. */
|
|
147
189
|
cancelAttempt?: Promise<boolean>;
|
|
148
190
|
/** Whether ANY overlapping cancel attempt for this prompt was acknowledged:
|
|
@@ -169,6 +211,7 @@ type PendingAttachment = { epoch: number; task: Promise<void> };
|
|
|
169
211
|
type SessionRecord = {
|
|
170
212
|
cwd: string;
|
|
171
213
|
adapter: AcpSdkAdapter;
|
|
214
|
+
primaryControlSurface: "cli" | "sdk";
|
|
172
215
|
attachment: SessionAttachment;
|
|
173
216
|
closeIdempotencyKey: string;
|
|
174
217
|
unsubscribe: () => void;
|
|
@@ -198,8 +241,78 @@ type SessionRecord = {
|
|
|
198
241
|
activePrompt?: PromptWaiter;
|
|
199
242
|
/** Set by `session/cancel` so an in-flight prompt settles as `cancelled`, never as an error. */
|
|
200
243
|
cancelRequested?: boolean;
|
|
244
|
+
/** True once the session's first logical prompt has settled; gates first-turn readiness retries. */
|
|
245
|
+
firstPromptDone?: boolean;
|
|
246
|
+
/** Whether the current prompt attempt was observed doing work; reset per attempt, read by the first-turn retry gate. */
|
|
247
|
+
promptObservedActivity?: boolean;
|
|
248
|
+
/** Whether the current prompt attempt executed a tool; reset per attempt, vetoes a first-turn retry that would re-run it. */
|
|
249
|
+
promptObservedToolExecution?: boolean;
|
|
250
|
+
/** Whether the current prompt attempt published assistant text/thought output; reset per attempt, vetoes a first-turn retry that would re-emit it. */
|
|
251
|
+
promptObservedAssistantOutput?: boolean;
|
|
252
|
+
/** Owner token held across the first-turn retry backoff; a non-owner prompt is rejected as a conflict (review P1). */
|
|
253
|
+
pendingFirstPromptRetry?: FirstPromptRetryReservation;
|
|
201
254
|
};
|
|
202
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Identity token proving a caller owns an in-progress first-turn retry. While it is held on the
|
|
258
|
+
* session record, `#submitPrompt` rejects any prompt that is not this owner, so a concurrent
|
|
259
|
+
* request cannot slip into the gap between `#settlePrompt` clearing `activePrompt` on the failed
|
|
260
|
+
* first attempt and the retry resubmitting after its backoff (review P1, issue #5574).
|
|
261
|
+
*/
|
|
262
|
+
type FirstPromptRetryReservation = {
|
|
263
|
+
readonly firstPromptRetry: true;
|
|
264
|
+
/**
|
|
265
|
+
* Set once `#submitPrompt` dispatched a turn for this prompt. Distinguishes a prompt that
|
|
266
|
+
* genuinely owned the session's first turn from a preflight rejection, so only the former
|
|
267
|
+
* settles `firstPromptDone` (review P2).
|
|
268
|
+
*/
|
|
269
|
+
admitted?: boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Terminal settlement recorded by a teardown that won the backoff gap. The failed attempt
|
|
272
|
+
* already cleared `activePrompt`, so `#teardownSession` has no waiter to settle for this
|
|
273
|
+
* caller and nothing else carries the outcome across the sleep. Without it the retry wakes
|
|
274
|
+
* to a deleted record and resubmits into `not_found`, where a client-driven close/delete
|
|
275
|
+
* owes the ACP `cancelled` stop reason (review P1).
|
|
276
|
+
*/
|
|
277
|
+
settlement?: { kind: "cancelled" } | { kind: "rejected"; error: AcpSdkAdapterError };
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Prompt-owned frame types that prove the turn is underway (as opposed to a
|
|
282
|
+
* terminal or a bare failure diagnostic). Observing one is the first-turn
|
|
283
|
+
* readiness-race fingerprint the retry gate keys on.
|
|
284
|
+
*/
|
|
285
|
+
function isTurnProgressEventType(type: string | undefined): boolean {
|
|
286
|
+
switch (type) {
|
|
287
|
+
case "agent_start":
|
|
288
|
+
case "message_start":
|
|
289
|
+
case "message_update":
|
|
290
|
+
case "message_end":
|
|
291
|
+
case "tool_execution_start":
|
|
292
|
+
case "tool_execution_update":
|
|
293
|
+
case "tool_execution_end":
|
|
294
|
+
return true;
|
|
295
|
+
default:
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Prompt-owned frame types that prove a tool actually executed. A turn that ran a tool did
|
|
302
|
+
* side-effecting work, so it must never be re-run by the first-turn readiness retry (issue
|
|
303
|
+
* #5574 / review P1): a re-submit is an independent turn that would repeat those side effects.
|
|
304
|
+
*/
|
|
305
|
+
function isTurnToolExecutionEventType(type: string | undefined): boolean {
|
|
306
|
+
switch (type) {
|
|
307
|
+
case "tool_execution_start":
|
|
308
|
+
case "tool_execution_update":
|
|
309
|
+
case "tool_execution_end":
|
|
310
|
+
return true;
|
|
311
|
+
default:
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
203
316
|
function promptWaiterRetired(record: SessionRecord, waiter: PromptWaiter): boolean {
|
|
204
317
|
return waiter.settled || record.activePrompt !== waiter;
|
|
205
318
|
}
|
|
@@ -496,6 +609,45 @@ function logDroppedPromptTerminal(
|
|
|
496
609
|
});
|
|
497
610
|
}
|
|
498
611
|
|
|
612
|
+
type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed" }>;
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A prompt rejection that still carries the terminal's structured failure classification.
|
|
616
|
+
* The ACP client sees only the code and message, but the first-turn retry gate must decide on
|
|
617
|
+
* what the terminal actually proved — the failure's execution phase and its bounded origin
|
|
618
|
+
* category — so settlement carries that classification instead of discarding it into a bare
|
|
619
|
+
* `AcpSdkAdapterError` (review P1, issue #5574).
|
|
620
|
+
*/
|
|
621
|
+
class AcpPromptFailureError extends AcpSdkAdapterError {
|
|
622
|
+
readonly failure: SdkPromptFailedOutcome;
|
|
623
|
+
constructor(failure: SdkPromptFailedOutcome) {
|
|
624
|
+
super(failure.code, failure.message);
|
|
625
|
+
this.failure = failure;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The startup-readiness failure class (issue #5574): the turn failed AFTER it started, with a
|
|
631
|
+
* bounded provider/transport classifier — the signature of a host that accepted the turn before
|
|
632
|
+
* its provider stream had finished coming up.
|
|
633
|
+
*
|
|
634
|
+
* Every other failure a `prompt_failed` terminal can carry is a genuine failure of this turn and
|
|
635
|
+
* is surfaced rather than re-submitted, even when it happened to publish nothing: a provider
|
|
636
|
+
* rejection (quota, refusal, 4xx), an agent runtime failure, a submission-phase rejection, or a
|
|
637
|
+
* deadline. Without this class check, "started, no output" alone authorized a retry, so any such
|
|
638
|
+
* rejection was silently re-run as a second turn (review P1).
|
|
639
|
+
*/
|
|
640
|
+
function isStartupReadinessFailure(error: unknown): boolean {
|
|
641
|
+
if (!(error instanceof AcpPromptFailureError)) return false;
|
|
642
|
+
const { failure } = error;
|
|
643
|
+
return (
|
|
644
|
+
failure.code === "prompt_failed" &&
|
|
645
|
+
failure.provenance === "agent_failed" &&
|
|
646
|
+
failure.phase === "post_start" &&
|
|
647
|
+
failure.category === "provider_transport"
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
499
651
|
function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefined {
|
|
500
652
|
const outcome = object(event.outcome);
|
|
501
653
|
if (!outcome) return undefined;
|
|
@@ -514,13 +666,15 @@ function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefine
|
|
|
514
666
|
(outcome.code === "prompt_failed" || outcome.code === "prompt_deadline_exceeded") &&
|
|
515
667
|
typeof outcome.message === "string" &&
|
|
516
668
|
(outcome.provenance === "agent_failed" || outcome.provenance === "deadline")
|
|
517
|
-
)
|
|
518
|
-
return {
|
|
519
|
-
kind: "failed",
|
|
669
|
+
) {
|
|
670
|
+
return failedPromptOutcome({
|
|
520
671
|
code: outcome.code,
|
|
521
|
-
message: outcome.message,
|
|
522
672
|
provenance: outcome.provenance,
|
|
523
|
-
|
|
673
|
+
...(typeof outcome.providerCode === "string" ? { providerCode: outcome.providerCode } : {}),
|
|
674
|
+
...(isSdkPromptFailurePhase(outcome.phase) ? { phase: outcome.phase } : {}),
|
|
675
|
+
evidence: {},
|
|
676
|
+
});
|
|
677
|
+
}
|
|
524
678
|
return undefined;
|
|
525
679
|
}
|
|
526
680
|
|
|
@@ -756,6 +910,22 @@ function configValues(query: unknown): Map<string, string> {
|
|
|
756
910
|
return values;
|
|
757
911
|
}
|
|
758
912
|
|
|
913
|
+
/**
|
|
914
|
+
* Recover a session-scoped preset only when the host's current model carries
|
|
915
|
+
* the synthetic profile namespace. A plain `modelPreset` value may be the
|
|
916
|
+
* persisted default and is therefore insufficient to identify an active
|
|
917
|
+
* profile, especially when attaching through the base ACP provider.
|
|
918
|
+
*/
|
|
919
|
+
export function activeModelPresetFromConfig(query: unknown): string | undefined {
|
|
920
|
+
const values = configValues(query);
|
|
921
|
+
const model = values.get(MODEL_CONFIG_ID);
|
|
922
|
+
const preset = values.get(MODEL_PRESET_CONFIG_KEY);
|
|
923
|
+
const prefix = `${SYNTHETIC_PROVIDER_ID}/`;
|
|
924
|
+
if (!model?.startsWith(prefix) || !preset) return undefined;
|
|
925
|
+
const syntheticPreset = model.slice(prefix.length);
|
|
926
|
+
return syntheticPreset === preset ? syntheticPreset : undefined;
|
|
927
|
+
}
|
|
928
|
+
|
|
759
929
|
function modelPresetConfigOptions(query: unknown, current: string): { value: string; name: string }[] {
|
|
760
930
|
const options = new Map<string, string>();
|
|
761
931
|
for (const item of pageItems(query)) {
|
|
@@ -1380,8 +1550,11 @@ export class AcpAgent implements Agent {
|
|
|
1380
1550
|
);
|
|
1381
1551
|
const id = sessionId(result);
|
|
1382
1552
|
this.#knownSessionCwds.set(id, params.cwd);
|
|
1383
|
-
this.#ownedSessionIds.add(id);
|
|
1384
1553
|
this.#knownSessionMcpServers.set(id, mcpServers);
|
|
1554
|
+
// This connection launched the host, so it owns the broker lifecycle before the
|
|
1555
|
+
// attachment reports a control surface. Without it a failed attach discards the
|
|
1556
|
+
// session as unowned and masks the real error with cleanup uncertainty.
|
|
1557
|
+
this.#ownedSessionIds.add(id);
|
|
1385
1558
|
try {
|
|
1386
1559
|
await this.#attach(id, params.cwd, undefined, result);
|
|
1387
1560
|
await applyAcpStartupOptions(this.#adapter(id), this.#startupOptions);
|
|
@@ -1437,8 +1610,10 @@ export class AcpAgent implements Agent {
|
|
|
1437
1610
|
);
|
|
1438
1611
|
const id = sessionId(result);
|
|
1439
1612
|
this.#knownSessionCwds.set(id, params.cwd);
|
|
1440
|
-
this.#ownedSessionIds.add(id);
|
|
1441
1613
|
this.#knownSessionMcpServers.set(id, mcpServers);
|
|
1614
|
+
// Forking launches a host through this connection, so lifecycle ownership is
|
|
1615
|
+
// established before attachment for the same reason as `newSession`.
|
|
1616
|
+
this.#ownedSessionIds.add(id);
|
|
1442
1617
|
try {
|
|
1443
1618
|
await this.#attach(id, params.cwd, undefined, result);
|
|
1444
1619
|
const response = { sessionId: id, ...(await this.#sessionState(id)) };
|
|
@@ -1484,8 +1659,10 @@ export class AcpAgent implements Agent {
|
|
|
1484
1659
|
|
|
1485
1660
|
closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
|
1486
1661
|
const record = this.#sessions.get(params.sessionId);
|
|
1487
|
-
// ACP close has no cwd.
|
|
1488
|
-
|
|
1662
|
+
// ACP close has no cwd. Broker lifecycle control stays owner-only, but a live
|
|
1663
|
+
// local attachment is always released: a CLI-primary session the client closed
|
|
1664
|
+
// must not keep its adapter, lease heartbeat, subscriptions and pending prompt.
|
|
1665
|
+
if (!this.#ownedSessionIds.has(params.sessionId) && record === undefined) return Promise.resolve({});
|
|
1489
1666
|
const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
|
|
1490
1667
|
if (!cwd) return Promise.resolve({});
|
|
1491
1668
|
return this.#enqueueLifecycleOperation(params.sessionId, async () => {
|
|
@@ -1500,7 +1677,17 @@ export class AcpAgent implements Agent {
|
|
|
1500
1677
|
const pendingLocator = this.#pendingDeleteLocators.get(params.sessionId);
|
|
1501
1678
|
// Capture authority before joining the lifecycle chain: an admitted delete must
|
|
1502
1679
|
// remain authorized when a preceding close retires connection ownership.
|
|
1503
|
-
if (!this.#ownedSessionIds.has(params.sessionId) && pendingLocator === undefined)
|
|
1680
|
+
if (!this.#ownedSessionIds.has(params.sessionId) && pendingLocator === undefined) {
|
|
1681
|
+
// Deleting a live session this connection does not own would claim a durable
|
|
1682
|
+
// deletion it never performs. Unknown ids stay the protocol no-op; a live
|
|
1683
|
+
// CLI-primary attachment is refused explicitly instead of faking success.
|
|
1684
|
+
if (record !== undefined)
|
|
1685
|
+
throw new AcpSdkAdapterError(
|
|
1686
|
+
"operation_prohibited",
|
|
1687
|
+
`ACP session ${params.sessionId} is owned by its terminal host; delete it from that session instead.`,
|
|
1688
|
+
);
|
|
1689
|
+
return {};
|
|
1690
|
+
}
|
|
1504
1691
|
const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId) ?? pendingLocator?.cwd;
|
|
1505
1692
|
return this.#enqueueLifecycleOperation(params.sessionId, async () => {
|
|
1506
1693
|
// ACP's delete request has no cwd. Unknown ids remain the protocol no-op,
|
|
@@ -1590,10 +1777,14 @@ export class AcpAgent implements Agent {
|
|
|
1590
1777
|
await this.setSessionMode({ sessionId: params.sessionId, modeId: params.value });
|
|
1591
1778
|
break;
|
|
1592
1779
|
case MODEL_CONFIG_ID:
|
|
1593
|
-
|
|
1594
|
-
await this.#adapter(params.sessionId).
|
|
1595
|
-
|
|
1596
|
-
|
|
1780
|
+
{
|
|
1781
|
+
const config = await this.#adapter(params.sessionId).query("config.list/get");
|
|
1782
|
+
const activePreset = this.#startupOptions?.modelPreset ?? activeModelPresetFromConfig(config);
|
|
1783
|
+
if (activePreset === undefined) {
|
|
1784
|
+
await this.#adapter(params.sessionId).setModel(params.value);
|
|
1785
|
+
} else if (params.value !== ACP_CUSTOM_MODEL_PRESET) {
|
|
1786
|
+
await this.#adapter(params.sessionId).control("model.profile.set", { id: params.value });
|
|
1787
|
+
}
|
|
1597
1788
|
}
|
|
1598
1789
|
break;
|
|
1599
1790
|
case THINKING_CONFIG_ID:
|
|
@@ -1614,8 +1805,168 @@ export class AcpAgent implements Agent {
|
|
|
1614
1805
|
}
|
|
1615
1806
|
|
|
1616
1807
|
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
|
1808
|
+
const record = this.#sessions.get(params.sessionId);
|
|
1809
|
+
// An unknown session must still report not_found; the retry loop only guards a
|
|
1810
|
+
// live session's first turn against a startup readiness race (issue #5574).
|
|
1811
|
+
if (!record) return await this.#submitPrompt(params, true);
|
|
1812
|
+
// Identity of THIS caller's first-turn retry. Held on the record across the backoff so a
|
|
1813
|
+
// concurrent prompt racing the gap is rejected as a conflict rather than admitted (review P1).
|
|
1814
|
+
const retryReservation: FirstPromptRetryReservation = { firstPromptRetry: true };
|
|
1815
|
+
try {
|
|
1816
|
+
let echoUserMessage = true;
|
|
1817
|
+
for (let attempt = 0; ; attempt++) {
|
|
1818
|
+
try {
|
|
1819
|
+
return await this.#submitPrompt(params, echoUserMessage, retryReservation);
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
if (!this.#shouldRetryFirstPrompt(record, error, attempt)) throw error;
|
|
1822
|
+
// The user's message was already echoed on the first attempt; re-echoing
|
|
1823
|
+
// would duplicate it in the client transcript.
|
|
1824
|
+
echoUserMessage = false;
|
|
1825
|
+
// Reserve the session BEFORE the backoff sleep. `#settlePrompt` already cleared
|
|
1826
|
+
// `activePrompt` when it rejected this attempt, so without the reservation the
|
|
1827
|
+
// backoff gap admits a competing prompt that steals the first turn (review P1).
|
|
1828
|
+
record.pendingFirstPromptRetry = retryReservation;
|
|
1829
|
+
logger.warn("ACP first-turn prompt failed; retrying after readiness backoff", {
|
|
1830
|
+
sessionId: params.sessionId,
|
|
1831
|
+
attempt: attempt + 1,
|
|
1832
|
+
maxRetries: ACP_FIRST_PROMPT_MAX_RETRIES,
|
|
1833
|
+
});
|
|
1834
|
+
await this.#delayFirstPromptRetry(attempt);
|
|
1835
|
+
// The failed attempt publishes its final text and its failure diagnostic on async
|
|
1836
|
+
// tails, and `#submitPrompt` rejects any prompt while one of them is still in
|
|
1837
|
+
// flight. A slow or backpressured ACP client keeps a tail running well past a
|
|
1838
|
+
// 250/500ms backoff, so an authorized retry would lose its own turn to a
|
|
1839
|
+
// `conflict` raised by its own predecessor's publications. Wait the tails out
|
|
1840
|
+
// here instead of weakening those guards, which still have to reject prompts
|
|
1841
|
+
// racing in from other callers (review P1).
|
|
1842
|
+
await this.#drainPromptPublicationTails(params.sessionId);
|
|
1843
|
+
// A session/close or session/delete that won the backoff gap tore the record
|
|
1844
|
+
// down without a waiter to settle, leaving its outcome on this reservation.
|
|
1845
|
+
// Honor it here: resubmitting would only reach `#submitPrompt`'s not_found,
|
|
1846
|
+
// which is not what a client-driven teardown owes the caller (review P1).
|
|
1847
|
+
const settlement = retryReservation.settlement;
|
|
1848
|
+
if (settlement) {
|
|
1849
|
+
if (settlement.kind === "cancelled") return { stopReason: "cancelled" };
|
|
1850
|
+
throw settlement.error;
|
|
1851
|
+
}
|
|
1852
|
+
// A session/cancel that arrived during the backoff gap (activePrompt already
|
|
1853
|
+
// cleared by the failed attempt, retry not yet resubmitted) must settle the
|
|
1854
|
+
// retry as `cancelled` instead of dispatching a fresh turn the client no
|
|
1855
|
+
// longer wants. The check lives here, before resubmission, because
|
|
1856
|
+
// `#submitPrompt` unconditionally clears `cancelRequested` for the new turn
|
|
1857
|
+
// it is about to start, so a later check would miss it (review P1).
|
|
1858
|
+
if (record.cancelRequested) {
|
|
1859
|
+
record.cancelRequested = false;
|
|
1860
|
+
return { stopReason: "cancelled" };
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
} finally {
|
|
1865
|
+
// Release the reservation on retry completion (success), cancellation, or final
|
|
1866
|
+
// failure. Guarded so a later prompt that took ownership is never cleared by this caller.
|
|
1867
|
+
if (record.pendingFirstPromptRetry === retryReservation) record.pendingFirstPromptRetry = undefined;
|
|
1868
|
+
// Only a turn that was actually admitted (dispatched to the host) settles the
|
|
1869
|
+
// session's first prompt. A preflight rejection — validation, auth/preflight, a
|
|
1870
|
+
// stale-state conflict, an oversize frame, or an ensureProviders() failure — never
|
|
1871
|
+
// owned a turn, so it must not consume the one-shot first-turn retry budget: a later
|
|
1872
|
+
// valid first prompt that hits the startup readiness race must still be retryable
|
|
1873
|
+
// (review P2).
|
|
1874
|
+
if (retryReservation.admitted) record.firstPromptDone = true;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* The readiness-race signature (issue #5574): the session's first turn was accepted
|
|
1880
|
+
* and started producing frames (an `agent_start`), then failed as `prompt_failed` before
|
|
1881
|
+
* ever completing. Re-submitting the same first prompt a bounded number of times lets a
|
|
1882
|
+
* host that has since finished coming up answer it, instead of ending the session on its
|
|
1883
|
+
* first turn.
|
|
1884
|
+
*
|
|
1885
|
+
* A retry re-submits the prompt as a fresh, INDEPENDENT `turn.prompt` — `AcpSdkAdapter`
|
|
1886
|
+
* assigns a new request id per call — so it must never re-run a turn that already did
|
|
1887
|
+
* side-effecting work. If the failed turn executed a tool, re-submitting would run the
|
|
1888
|
+
* user's instruction, and its side effects, a second time (review P1). A turn that only
|
|
1889
|
+
* started (agent_start / streamed text) but ran no tool produced no external effect, so
|
|
1890
|
+
* recovering it is safe.
|
|
1891
|
+
*
|
|
1892
|
+
* Every gate is deliberately narrow so a genuine failure is still surfaced:
|
|
1893
|
+
* - only the first logical prompt of the session (`!firstPromptDone`);
|
|
1894
|
+
* - only the startup-readiness failure class the terminal itself classified
|
|
1895
|
+
* (`isStartupReadinessFailure`): a post-start `agent_failed` `prompt_failed` with a
|
|
1896
|
+
* provider/transport classifier — never a client cancel, a `prompt_deadline_exceeded`,
|
|
1897
|
+
* a provider rejection, an agent runtime failure, a transport error, or a preflight
|
|
1898
|
+
* rejection, however little output they happened to produce;
|
|
1899
|
+
* - only after the turn was observed starting (`promptObservedActivity`), the race's
|
|
1900
|
+
* fingerprint, which a turn rejected before it ever started never has;
|
|
1901
|
+
* - but NEVER once the turn executed a tool (`promptObservedToolExecution`), so a
|
|
1902
|
+
* progressed, side-effecting turn is never re-run as a second turn;
|
|
1903
|
+
* - but NEVER once the turn published assistant text/thought output
|
|
1904
|
+
* (`promptObservedAssistantOutput`), so a re-submit cannot expose duplicated output
|
|
1905
|
+
* from both attempts to the live ACP update stream — including a failed terminal's own
|
|
1906
|
+
* `finalText`, which is published on an async tail after this gate runs;
|
|
1907
|
+
* - never once the client has asked to cancel.
|
|
1908
|
+
*/
|
|
1909
|
+
#shouldRetryFirstPrompt(record: SessionRecord, error: unknown, attempt: number): boolean {
|
|
1910
|
+
if (attempt >= ACP_FIRST_PROMPT_MAX_RETRIES) return false;
|
|
1911
|
+
// The primary gate: only the failure class the terminal classified as a startup
|
|
1912
|
+
// readiness race is recoverable. The activity/tool/output gates below stay as secondary
|
|
1913
|
+
// safety checks on top of it — they bound what may be re-run, not what is worth re-running.
|
|
1914
|
+
if (!isStartupReadinessFailure(error)) return false;
|
|
1915
|
+
if (record.firstPromptDone) return false;
|
|
1916
|
+
if (record.cancelRequested) return false;
|
|
1917
|
+
if (!record.promptObservedActivity) return false;
|
|
1918
|
+
// Never re-run a turn that already executed a tool: a re-submit is a new, independent
|
|
1919
|
+
// turn.prompt, so repeating a tool-executing turn would run the user's instruction and
|
|
1920
|
+
// its side effects a second time (review P1).
|
|
1921
|
+
if (record.promptObservedToolExecution) return false;
|
|
1922
|
+
// Never re-run a turn that already published — or has committed to publishing —
|
|
1923
|
+
// assistant text/thought output: a re-submit is a new, independent turn.prompt whose
|
|
1924
|
+
// output would be delivered to ACP consumers on top of the failed attempt's chunks or
|
|
1925
|
+
// its terminal's final text, duplicating or corrupting the assistant stream.
|
|
1926
|
+
if (record.promptObservedAssistantOutput) return false;
|
|
1927
|
+
return true;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* Settles the publication tails a finished turn left running: its terminal's final text, its
|
|
1932
|
+
* end-of-turn metadata, and its failure diagnostic. Each tail owns one of `#submitPrompt`'s
|
|
1933
|
+
* conflict guards, and a tail can start another (the failure diagnostic awaits the final text),
|
|
1934
|
+
* so this drains until none is left rather than sampling once.
|
|
1935
|
+
*/
|
|
1936
|
+
async #drainPromptPublicationTails(id: string): Promise<void> {
|
|
1937
|
+
for (;;) {
|
|
1938
|
+
const tails = [
|
|
1939
|
+
this.#finalTextTails.get(id),
|
|
1940
|
+
this.#terminalMetadataTails.get(id),
|
|
1941
|
+
this.#failureDiagnosticTails.get(id),
|
|
1942
|
+
].filter((tail): tail is Promise<void> => tail !== undefined);
|
|
1943
|
+
if (tails.length === 0) return;
|
|
1944
|
+
// A publication failure is the tail's own business; it is logged where it happens and
|
|
1945
|
+
// must not decide the retry.
|
|
1946
|
+
await Promise.allSettled(tails);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/** Backoff between first-prompt retries, on the injectable watchdog clock so tests can advance it. */
|
|
1951
|
+
#delayFirstPromptRetry(attempt: number): Promise<void> {
|
|
1952
|
+
const delayMs = ACP_FIRST_PROMPT_RETRY_BASE_DELAY_MS * (attempt + 1);
|
|
1953
|
+
return new Promise<void>(resolve => {
|
|
1954
|
+
this.#promptWatchdogClock.schedule(resolve, delayMs);
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
async #submitPrompt(
|
|
1959
|
+
params: PromptRequest,
|
|
1960
|
+
echoUserMessage: boolean,
|
|
1961
|
+
retryReservation?: FirstPromptRetryReservation,
|
|
1962
|
+
): Promise<PromptResponse> {
|
|
1617
1963
|
const record = this.#sessions.get(params.sessionId);
|
|
1618
1964
|
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
|
|
1965
|
+
// A first-turn retry holds the session across its backoff gap. Any prompt that is not the
|
|
1966
|
+
// retry owner is rejected here, before any state mutation or dispatch, so it cannot steal
|
|
1967
|
+
// the first turn while the authorized retry is still pending (review P1, issue #5574).
|
|
1968
|
+
if (record.pendingFirstPromptRetry && record.pendingFirstPromptRetry !== retryReservation)
|
|
1969
|
+
throw new AcpSdkAdapterError("conflict", "ACP session is retrying its first prompt.");
|
|
1619
1970
|
if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
|
|
1620
1971
|
if (record.authFailure) throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
|
|
1621
1972
|
if (this.#retiredPromptAcknowledgements.has(params.sessionId))
|
|
@@ -1684,6 +2035,11 @@ export class AcpAgent implements Agent {
|
|
|
1684
2035
|
)} KiB transport limit. Attach a smaller or more compressed image.`,
|
|
1685
2036
|
);
|
|
1686
2037
|
record.publicationGeneration++;
|
|
2038
|
+
// Reset per attempt: a first-turn readiness retry keys on whether THIS attempt was
|
|
2039
|
+
// observed doing work, never a prior attempt's activity.
|
|
2040
|
+
record.promptObservedActivity = false;
|
|
2041
|
+
record.promptObservedToolExecution = false;
|
|
2042
|
+
record.promptObservedAssistantOutput = false;
|
|
1687
2043
|
const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
|
|
1688
2044
|
const waiter: PromptWaiter = {
|
|
1689
2045
|
acknowledged: false,
|
|
@@ -1700,6 +2056,8 @@ export class AcpAgent implements Agent {
|
|
|
1700
2056
|
lastFrameAt: this.#promptWatchdogClock.now(),
|
|
1701
2057
|
lastFrameType: "prompt_dispatch",
|
|
1702
2058
|
activity: new PromptActivity(),
|
|
2059
|
+
observedTurnActivity: false,
|
|
2060
|
+
observedToolExecution: false,
|
|
1703
2061
|
resolve,
|
|
1704
2062
|
reject,
|
|
1705
2063
|
};
|
|
@@ -1746,19 +2104,26 @@ export class AcpAgent implements Agent {
|
|
|
1746
2104
|
// image never appear in the client UI — only the agent's reply does. Replay
|
|
1747
2105
|
// (session/load) already emits these; a live turn must too, and the image blocks
|
|
1748
2106
|
// must be published verbatim so attachments are visible, not just fed to the model.
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2107
|
+
// A first-turn readiness retry (issue #5574) skips the echo: the message was already
|
|
2108
|
+
// published on the first attempt and re-echoing would duplicate it in the transcript.
|
|
2109
|
+
if (echoUserMessage)
|
|
2110
|
+
for (const block of params.prompt) {
|
|
2111
|
+
if (block.type !== "text" && block.type !== "image") continue;
|
|
2112
|
+
if (block.type === "text" && block.text.length === 0) continue;
|
|
2113
|
+
await this.#publishSessionUpdate(
|
|
2114
|
+
params.sessionId,
|
|
2115
|
+
{
|
|
2116
|
+
sessionId: params.sessionId,
|
|
2117
|
+
update: { sessionUpdate: "user_message_chunk", content: block },
|
|
2118
|
+
},
|
|
2119
|
+
record.adapter,
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
1761
2122
|
waiter.dispatched = true;
|
|
2123
|
+
// The turn is now dispatched to the host: this prompt owned the session's first turn,
|
|
2124
|
+
// so it — not a preflight rejection that threw before this point — is what settles
|
|
2125
|
+
// `firstPromptDone` in `prompt()` (review P2).
|
|
2126
|
+
if (retryReservation) retryReservation.admitted = true;
|
|
1762
2127
|
if (waiter.settled || record.activePrompt !== waiter) {
|
|
1763
2128
|
return await response;
|
|
1764
2129
|
}
|
|
@@ -1843,6 +2208,8 @@ export class AcpAgent implements Agent {
|
|
|
1843
2208
|
}
|
|
1844
2209
|
if (matchesWaiter) {
|
|
1845
2210
|
this.#observePromptActivity(waiter, deferredFrame);
|
|
2211
|
+
if (waiter.observedTurnActivity) record.promptObservedActivity = true;
|
|
2212
|
+
if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
|
|
1846
2213
|
observedDeferredActivity = true;
|
|
1847
2214
|
}
|
|
1848
2215
|
}
|
|
@@ -2029,7 +2396,11 @@ export class AcpAgent implements Agent {
|
|
|
2029
2396
|
}
|
|
2030
2397
|
waiter?.cancelAttemptResolve?.(true);
|
|
2031
2398
|
} catch (error) {
|
|
2032
|
-
|
|
2399
|
+
// With no active prompt the cancel intent normally clears — except when a first-turn
|
|
2400
|
+
// retry is reserved across its backoff gap. That reservation owner has not yet observed
|
|
2401
|
+
// the cancel; clearing it here would let the retry resubmit a turn the client cancelled
|
|
2402
|
+
// (review P1). Leave the flag for the retry's post-backoff check to settle as cancelled.
|
|
2403
|
+
if (!waiter && !record.pendingFirstPromptRetry) record.cancelRequested = false;
|
|
2033
2404
|
// Only the LAST in-flight attempt resolves the shared promise false;
|
|
2034
2405
|
// an earlier attempt may still acknowledge (review thread P2). After
|
|
2035
2406
|
// every attempt of this wave failed, RE-ARM the aggregate: a later
|
|
@@ -2227,7 +2598,7 @@ export class AcpAgent implements Agent {
|
|
|
2227
2598
|
// session host remains authoritative for its immutable configuration, so
|
|
2228
2599
|
// attachment must not reinterpret the replay as a mutation request.
|
|
2229
2600
|
this.#pendingDeleteLocators.delete(id);
|
|
2230
|
-
await attached.adapter.ensureProviders();
|
|
2601
|
+
if (attached.primaryControlSurface === "sdk") await attached.adapter.ensureProviders();
|
|
2231
2602
|
return;
|
|
2232
2603
|
}
|
|
2233
2604
|
const knownCwd = this.#knownSessionCwds.get(id);
|
|
@@ -2313,7 +2684,7 @@ export class AcpAgent implements Agent {
|
|
|
2313
2684
|
if (existing) {
|
|
2314
2685
|
if (path.resolve(existing.cwd) !== path.resolve(cwd))
|
|
2315
2686
|
throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
|
|
2316
|
-
await existing.adapter.ensureProviders();
|
|
2687
|
+
if (existing.primaryControlSurface === "sdk") await existing.adapter.ensureProviders();
|
|
2317
2688
|
return;
|
|
2318
2689
|
}
|
|
2319
2690
|
const attaching = this.#attaching.get(id);
|
|
@@ -2368,7 +2739,10 @@ export class AcpAgent implements Agent {
|
|
|
2368
2739
|
});
|
|
2369
2740
|
unsubscribePendingFrames = adapter.onFrame(frame => pendingAdapterFrames.push(frame));
|
|
2370
2741
|
this.#pendingRouterAdapters.set(id, adapter);
|
|
2371
|
-
|
|
2742
|
+
// Query startup provenance before activating reverse providers. A CLI
|
|
2743
|
+
// host must retain terminal permission/UI leases and lifecycle authority;
|
|
2744
|
+
// a broker-launched SDK host keeps ACP ownership across reconnects.
|
|
2745
|
+
await adapter.start({ activateProviders: false });
|
|
2372
2746
|
let capabilities: JsonObject | undefined;
|
|
2373
2747
|
try {
|
|
2374
2748
|
const response = object(await adapter.query("runtime.capabilities"));
|
|
@@ -2377,11 +2751,22 @@ export class AcpAgent implements Agent {
|
|
|
2377
2751
|
// page item, so fall back to the envelope only for direct-result hosts.
|
|
2378
2752
|
capabilities = object(pageItems(result)[0]) ?? result;
|
|
2379
2753
|
} catch {}
|
|
2380
|
-
|
|
2754
|
+
const primaryControlSurface =
|
|
2755
|
+
capabilities?.primaryControlSurface === "sdk"
|
|
2756
|
+
? "sdk"
|
|
2757
|
+
: capabilities?.primaryControlSurface === "cli"
|
|
2758
|
+
? "cli"
|
|
2759
|
+
: undefined;
|
|
2760
|
+
if (capabilities?.promptTerminalOutcomeVersion !== 1 || primaryControlSurface === undefined)
|
|
2381
2761
|
throw new AcpSdkAdapterError(
|
|
2382
2762
|
"unavailable",
|
|
2383
|
-
"This ACP client requires a newer GJC SDK session; restart the session.",
|
|
2763
|
+
"This ACP client requires a newer GJC SDK session with startup control provenance; restart the session.",
|
|
2384
2764
|
);
|
|
2765
|
+
if (primaryControlSurface === "sdk") {
|
|
2766
|
+
adapter.authorizeProviderActivation();
|
|
2767
|
+
await adapter.ensureProviders();
|
|
2768
|
+
await applyAcpPermissionMode(adapter, this.#clientCapabilities);
|
|
2769
|
+
}
|
|
2385
2770
|
this.#assertSessionEpoch(id, epoch);
|
|
2386
2771
|
const exactAttachment = this.#router.attachment(id) ?? currentAttachment;
|
|
2387
2772
|
if (!exactAttachment.isCurrent())
|
|
@@ -2389,6 +2774,7 @@ export class AcpAgent implements Agent {
|
|
|
2389
2774
|
const record: SessionRecord = {
|
|
2390
2775
|
cwd,
|
|
2391
2776
|
adapter,
|
|
2777
|
+
primaryControlSurface,
|
|
2392
2778
|
attachment: exactAttachment,
|
|
2393
2779
|
closeIdempotencyKey: randomUUID(),
|
|
2394
2780
|
unsubscribe: () => {},
|
|
@@ -2404,6 +2790,12 @@ export class AcpAgent implements Agent {
|
|
|
2404
2790
|
backgroundCorrelations: [],
|
|
2405
2791
|
toolArgs: new Map(),
|
|
2406
2792
|
};
|
|
2793
|
+
// Preserve first-turn state across reattachment (issue #5574). The startup-readiness
|
|
2794
|
+
// retry only guards a session's very first logical prompt; a fresh record built on
|
|
2795
|
+
// reconnect/reattach would otherwise reset that guard and let a later prompt take the
|
|
2796
|
+
// retry — and re-run — path. Reattachment retains the session's settled prompt
|
|
2797
|
+
// correlations, so a non-empty set proves at least one prompt has already completed.
|
|
2798
|
+
if (record.settledPromptCorrelations.length > 0) record.firstPromptDone = true;
|
|
2407
2799
|
record.unsubscribe = adapter.onFrame(frame => this.#enqueueSdkFrame(id, adapter!, frame));
|
|
2408
2800
|
record.reconnectUnsubscribe = adapter.onReconnectFailed(error =>
|
|
2409
2801
|
this.#recoverSessionAfterTransportFailure(id, adapter!, error),
|
|
@@ -2415,8 +2807,10 @@ export class AcpAgent implements Agent {
|
|
|
2415
2807
|
this.#pendingRouterAdapters.delete(id);
|
|
2416
2808
|
this.#pendingRouterFrames.delete(id);
|
|
2417
2809
|
this.#knownSessionCwds.set(id, cwd);
|
|
2418
|
-
|
|
2419
|
-
|
|
2810
|
+
// Host provenance is authoritative for lifecycle authority: a CLI-primary host
|
|
2811
|
+
// keeps it in its terminal even when this connection created the session.
|
|
2812
|
+
if (primaryControlSurface === "sdk") this.#ownedSessionIds.add(id);
|
|
2813
|
+
else this.#ownedSessionIds.delete(id);
|
|
2420
2814
|
this.#assertSessionEpoch(id, epoch);
|
|
2421
2815
|
// A successful attachment establishes a new live-owner epoch. Any locator
|
|
2422
2816
|
// retained from an earlier cleanup_pending delete belongs to the terminal
|
|
@@ -2552,7 +2946,9 @@ export class AcpAgent implements Agent {
|
|
|
2552
2946
|
// The record is published before permission initialization. Let a canceled
|
|
2553
2947
|
// provisional attachment retire it before selecting the generation key.
|
|
2554
2948
|
if (attaching) await Promise.allSettled([attaching.task]);
|
|
2555
|
-
|
|
2949
|
+
// A CLI-primary host owns its own process lifecycle: release this ACP
|
|
2950
|
+
// attachment locally and never ask the broker to close the session.
|
|
2951
|
+
await this.#teardownSession(id, "closed", this.#ownedSessionIds.has(id));
|
|
2556
2952
|
this.#knownSessionCwds.delete(id);
|
|
2557
2953
|
this.#ownedSessionIds.delete(id);
|
|
2558
2954
|
this.#knownSessionMcpServers.delete(id);
|
|
@@ -2586,19 +2982,36 @@ export class AcpAgent implements Agent {
|
|
|
2586
2982
|
record.unsubscribe();
|
|
2587
2983
|
record.reconnectUnsubscribe();
|
|
2588
2984
|
record.activePrompt = undefined;
|
|
2589
|
-
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2985
|
+
const voluntary = reason === "closed" || reason === "discarded" || reason === "deleted";
|
|
2986
|
+
// `session/close` and `session/delete` are the client asking to end its own work, so
|
|
2987
|
+
// the pending turn settles as `cancelled` rather than surfacing a spurious error.
|
|
2988
|
+
// ACP: "Agents MUST catch these errors and return the semantically meaningful
|
|
2989
|
+
// `cancelled` stop reason." Involuntary teardown (transport loss) still rejects.
|
|
2593
2990
|
if (waiter && !waiter.settled) {
|
|
2594
2991
|
clearPromptWatchdog(waiter);
|
|
2595
|
-
if (
|
|
2992
|
+
if (voluntary) {
|
|
2596
2993
|
waiter.settled = true;
|
|
2597
2994
|
waiter.resolve({ stopReason: "cancelled" });
|
|
2598
2995
|
} else {
|
|
2599
2996
|
waiter.reject(new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`));
|
|
2600
2997
|
}
|
|
2601
2998
|
}
|
|
2999
|
+
// A first-turn retry sleeping out its backoff owns no waiter — its failed attempt
|
|
3000
|
+
// already cleared `activePrompt` — so the settlement above cannot reach it. Record
|
|
3001
|
+
// the teardown on its reservation instead, with the same voluntary/involuntary
|
|
3002
|
+
// split, and release the reservation: otherwise the retry wakes to a deleted
|
|
3003
|
+
// record and resubmits, surfacing `not_found` where the client's close/delete owes
|
|
3004
|
+
// `cancelled` (review P1).
|
|
3005
|
+
const reservation = record.pendingFirstPromptRetry;
|
|
3006
|
+
if (reservation) {
|
|
3007
|
+
reservation.settlement ??= voluntary
|
|
3008
|
+
? { kind: "cancelled" }
|
|
3009
|
+
: {
|
|
3010
|
+
kind: "rejected",
|
|
3011
|
+
error: new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`),
|
|
3012
|
+
};
|
|
3013
|
+
record.pendingFirstPromptRetry = undefined;
|
|
3014
|
+
}
|
|
2602
3015
|
}
|
|
2603
3016
|
|
|
2604
3017
|
const failures: unknown[] = [];
|
|
@@ -2809,6 +3222,8 @@ export class AcpAgent implements Agent {
|
|
|
2809
3222
|
}
|
|
2810
3223
|
if (!correlationsExactlyMatch(waiter.correlation, correlation)) return;
|
|
2811
3224
|
this.#observePromptActivity(waiter, frame);
|
|
3225
|
+
if (waiter.observedTurnActivity) record.promptObservedActivity = true;
|
|
3226
|
+
if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
|
|
2812
3227
|
this.#armPromptWatchdog(id, record, waiter);
|
|
2813
3228
|
}
|
|
2814
3229
|
|
|
@@ -2817,6 +3232,10 @@ export class AcpAgent implements Agent {
|
|
|
2817
3232
|
waiter.lastFrameAt = this.#promptWatchdogClock.now();
|
|
2818
3233
|
waiter.lastFrameType =
|
|
2819
3234
|
typeof event?.type === "string" ? event.type : typeof frame.type === "string" ? frame.type : "unknown";
|
|
3235
|
+
if (isTurnProgressEventType(typeof event?.type === "string" ? event.type : undefined))
|
|
3236
|
+
waiter.observedTurnActivity = true;
|
|
3237
|
+
if (isTurnToolExecutionEventType(typeof event?.type === "string" ? event.type : undefined))
|
|
3238
|
+
waiter.observedToolExecution = true;
|
|
2820
3239
|
waiter.activity.observe(
|
|
2821
3240
|
typeof event?.type === "string" ? event.type : undefined,
|
|
2822
3241
|
typeof event?.toolCallId === "string" ? event.toolCallId : undefined,
|
|
@@ -3031,7 +3450,24 @@ export class AcpAgent implements Agent {
|
|
|
3031
3450
|
);
|
|
3032
3451
|
return;
|
|
3033
3452
|
}
|
|
3034
|
-
|
|
3453
|
+
// When a client cancel has been requested, a trailing stopped terminal may
|
|
3454
|
+
// still carry the normal `end_turn` reason (the model finished its response as
|
|
3455
|
+
// the cancel arrived mid-stream, or the cancel was processed before the prompt
|
|
3456
|
+
// was acknowledged). The client's cancellation is the authoritative cause;
|
|
3457
|
+
// remap only `end_turn` — deliberate host reasons (e.g. `refusal`, `max_tokens`)
|
|
3458
|
+
// remain authoritative and must NOT be overridden.
|
|
3459
|
+
const settledOutcome =
|
|
3460
|
+
record.cancelRequested && outcome.kind === "stopped" && outcome.reason === "end_turn"
|
|
3461
|
+
? { ...outcome, reason: "cancelled" as const, provenance: "client_cancel" as const }
|
|
3462
|
+
: outcome;
|
|
3463
|
+
activePrompt.terminal = { outcome: settledOutcome, correlation };
|
|
3464
|
+
// A terminal carrying final text still publishes it as an assistant chunk, on the
|
|
3465
|
+
// async tail `#scheduleTerminalUpdates` starts below. A failed terminal can carry it
|
|
3466
|
+
// with no preceding stream chunks at all, so the streamed-chunk flag stays false and
|
|
3467
|
+
// the first-turn retry gate — which the settlement below releases — would see
|
|
3468
|
+
// "started, no output", resubmit, and let BOTH this terminal's final text and the
|
|
3469
|
+
// retry's answer reach ACP consumers. Record the output before settling (review P1).
|
|
3470
|
+
if (typeof event.finalText === "string" && event.finalText) record.promptObservedAssistantOutput = true;
|
|
3035
3471
|
// Failure diagnostics are useful but advisory. Settle before any mapped
|
|
3036
3472
|
// session update can await a backpressured client transport; otherwise an
|
|
3037
3473
|
// already-decided failure can still lose to the inactivity watchdog.
|
|
@@ -3098,6 +3534,16 @@ export class AcpAgent implements Agent {
|
|
|
3098
3534
|
notification.update.content.type === "text"
|
|
3099
3535
|
)
|
|
3100
3536
|
promptOwner.emittedAssistantText += notification.update.content.text;
|
|
3537
|
+
// A live assistant text/thought chunk for this prompt owner has now been exposed to
|
|
3538
|
+
// ACP consumers. Record it so the first-turn readiness retry is vetoed: a re-submit
|
|
3539
|
+
// would deliver a second attempt's output on top of these chunks (issue #5574).
|
|
3540
|
+
if (
|
|
3541
|
+
promptOwner &&
|
|
3542
|
+
event.type !== "agent_failed" &&
|
|
3543
|
+
(notification.update.sessionUpdate === "agent_message_chunk" ||
|
|
3544
|
+
notification.update.sessionUpdate === "agent_thought_chunk")
|
|
3545
|
+
)
|
|
3546
|
+
record.promptObservedAssistantOutput = true;
|
|
3101
3547
|
// The prompt rejection carries the sanitized failure diagnostic. Publishing
|
|
3102
3548
|
// a second session update after settlement would be stale as soon as the client
|
|
3103
3549
|
// starts a replacement turn, and an in-flight transport write cannot be revoked.
|
|
@@ -3344,7 +3790,16 @@ export class AcpAgent implements Agent {
|
|
|
3344
3790
|
waiter.resolve({ stopReason: outcome.reason });
|
|
3345
3791
|
return;
|
|
3346
3792
|
}
|
|
3347
|
-
|
|
3793
|
+
// `phase` is the host's own claim, and a host that omits it leaves `terminalOutcome`
|
|
3794
|
+
// deriving the phase with no evidence at all — `submission`, even for a turn this
|
|
3795
|
+
// connection watched start. Upgrade it from the frames this prompt actually owned, never
|
|
3796
|
+
// downgrade a terminal that already reported `post_start`, and carry the whole
|
|
3797
|
+
// classification on the rejection so the first-turn retry gate can read it (review P1).
|
|
3798
|
+
const failure =
|
|
3799
|
+
outcome.phase === "post_start"
|
|
3800
|
+
? outcome
|
|
3801
|
+
: (rephaseFailedOutcome(outcome, { hasActivity: waiter.observedTurnActivity }) as SdkPromptFailedOutcome);
|
|
3802
|
+
waiter.reject(new AcpPromptFailureError(failure));
|
|
3348
3803
|
}
|
|
3349
3804
|
|
|
3350
3805
|
async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter, publicationGeneration: number): Promise<void> {
|
|
@@ -3473,25 +3928,20 @@ export class AcpAgent implements Agent {
|
|
|
3473
3928
|
): Promise<Pick<NewSessionResponse, "configOptions" | "modes">> {
|
|
3474
3929
|
const record = this.#sessions.get(id);
|
|
3475
3930
|
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${id}`);
|
|
3476
|
-
const
|
|
3477
|
-
const
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
//
|
|
3489
|
-
//
|
|
3490
|
-
// credential state, so catalog rows and provider availability never mix
|
|
3491
|
-
// pre- and post-refresh credential snapshots. Only an older session host
|
|
3492
|
-
// that rejects `providers.list/active` with `operation_not_session_owned`
|
|
3493
|
-
// falls back to the full catalog; operational failures fail closed so the
|
|
3494
|
-
// active-provider contract is not silently widened.
|
|
3931
|
+
const config = await record.adapter.query("config.list/get");
|
|
3932
|
+
const modelPreset = this.#startupOptions?.modelPreset ?? activeModelPresetFromConfig(config);
|
|
3933
|
+
const sessionCatalog = await (modelPreset === undefined
|
|
3934
|
+
? collectModelCatalogAndActiveProviders(record.adapter)
|
|
3935
|
+
: record.adapter
|
|
3936
|
+
.query("models.profiles.list")
|
|
3937
|
+
.then((profiles): { modelCatalog: unknown; activeProviders: undefined } => ({
|
|
3938
|
+
modelCatalog: profiles,
|
|
3939
|
+
activeProviders: undefined,
|
|
3940
|
+
})));
|
|
3941
|
+
// Only an older session host that rejects `providers.list/active` with
|
|
3942
|
+
// `operation_not_session_owned` falls back to the full catalog;
|
|
3943
|
+
// operational failures fail closed so the active-provider contract is not
|
|
3944
|
+
// silently widened.
|
|
3495
3945
|
const modelCatalog = sessionCatalog.modelCatalog;
|
|
3496
3946
|
const activeProviders = sessionCatalog.activeProviders;
|
|
3497
3947
|
record.authFailure = undefined;
|
|
@@ -3787,6 +4237,10 @@ export class AcpAgent implements Agent {
|
|
|
3787
4237
|
);
|
|
3788
4238
|
}
|
|
3789
4239
|
const messageId = typeof message.id === "string" ? message.id : undefined;
|
|
4240
|
+
// A replayed user turn is proof the loaded session already had a prompt, so the
|
|
4241
|
+
// next live prompt is not its first — keep the first-turn readiness retry (issue
|
|
4242
|
+
// #5574) off for a resumed/loaded session that has prior history.
|
|
4243
|
+
if (message.role === "user") record.firstPromptDone = true;
|
|
3790
4244
|
const richContent = Array.isArray(message.content) ? message.content : undefined;
|
|
3791
4245
|
if ((message.role === "user" || message.role === "assistant") && richContent) {
|
|
3792
4246
|
for (const rawBlock of richContent) {
|