zcode-acp-server 0.1.0 → 0.3.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/README.md +192 -9
- package/README.zh-CN.md +143 -8
- package/dist/backend/client.d.ts +12 -4
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +72 -7
- package/dist/backend/client.js.map +1 -1
- package/dist/backend/credentials.d.ts.map +1 -1
- package/dist/backend/credentials.js +2 -1
- package/dist/backend/credentials.js.map +1 -1
- package/dist/backend/listener.d.ts +12 -4
- package/dist/backend/listener.d.ts.map +1 -1
- package/dist/backend/listener.js +68 -12
- package/dist/backend/listener.js.map +1 -1
- package/dist/backend/types.d.ts +1 -1
- package/dist/backend/types.d.ts.map +1 -1
- package/dist/bin/hub.d.ts +16 -0
- package/dist/bin/hub.d.ts.map +1 -0
- package/dist/bin/hub.js +41 -0
- package/dist/bin/hub.js.map +1 -0
- package/dist/bin/quota.d.ts +54 -0
- package/dist/bin/quota.d.ts.map +1 -0
- package/dist/bin/quota.js +333 -0
- package/dist/bin/quota.js.map +1 -0
- package/dist/config/auto-compact.d.ts +23 -0
- package/dist/config/auto-compact.d.ts.map +1 -0
- package/dist/config/auto-compact.js +67 -0
- package/dist/config/auto-compact.js.map +1 -0
- package/dist/config/mcp-discovery.d.ts +34 -0
- package/dist/config/mcp-discovery.d.ts.map +1 -0
- package/dist/config/mcp-discovery.js +153 -0
- package/dist/config/mcp-discovery.js.map +1 -0
- package/dist/config/model-cache.d.ts +12 -1
- package/dist/config/model-cache.d.ts.map +1 -1
- package/dist/config/model-cache.js +30 -8
- package/dist/config/model-cache.js.map +1 -1
- package/dist/config/options.d.ts +87 -9
- package/dist/config/options.d.ts.map +1 -1
- package/dist/config/options.js +248 -45
- package/dist/config/options.js.map +1 -1
- package/dist/config/plugin-commands.d.ts +24 -0
- package/dist/config/plugin-commands.d.ts.map +1 -0
- package/dist/config/plugin-commands.js +107 -0
- package/dist/config/plugin-commands.js.map +1 -0
- package/dist/config/provider-registry.d.ts +60 -0
- package/dist/config/provider-registry.d.ts.map +1 -0
- package/dist/config/provider-registry.js +128 -0
- package/dist/config/provider-registry.js.map +1 -0
- package/dist/config/runtime-model.d.ts +54 -18
- package/dist/config/runtime-model.d.ts.map +1 -1
- package/dist/config/runtime-model.js +105 -61
- package/dist/config/runtime-model.js.map +1 -1
- package/dist/config/skill-discovery.d.ts +35 -0
- package/dist/config/skill-discovery.d.ts.map +1 -0
- package/dist/config/skill-discovery.js +188 -0
- package/dist/config/skill-discovery.js.map +1 -0
- package/dist/handlers/account.d.ts +43 -0
- package/dist/handlers/account.d.ts.map +1 -0
- package/dist/handlers/account.js +59 -0
- package/dist/handlers/account.js.map +1 -0
- package/dist/handlers/background-tasks.d.ts +72 -0
- package/dist/handlers/background-tasks.d.ts.map +1 -0
- package/dist/handlers/background-tasks.js +330 -0
- package/dist/handlers/background-tasks.js.map +1 -0
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +134 -22
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/extensions.d.ts +15 -0
- package/dist/handlers/extensions.d.ts.map +1 -1
- package/dist/handlers/extensions.js +43 -27
- package/dist/handlers/extensions.js.map +1 -1
- package/dist/handlers/io.d.ts +31 -3
- package/dist/handlers/io.d.ts.map +1 -1
- package/dist/handlers/io.js +108 -9
- package/dist/handlers/io.js.map +1 -1
- package/dist/handlers/replay.d.ts +79 -0
- package/dist/handlers/replay.d.ts.map +1 -0
- package/dist/handlers/replay.js +252 -0
- package/dist/handlers/replay.js.map +1 -0
- package/dist/handlers/server-requests.d.ts +20 -4
- package/dist/handlers/server-requests.d.ts.map +1 -1
- package/dist/handlers/server-requests.js +293 -63
- package/dist/handlers/server-requests.js.map +1 -1
- package/dist/handlers/session.d.ts +113 -10
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +932 -277
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts +37 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +143 -8
- package/dist/handlers/slash.js.map +1 -1
- package/dist/index.js +76 -12
- package/dist/index.js.map +1 -1
- package/dist/interaction/adapter.d.ts +19 -33
- package/dist/interaction/adapter.d.ts.map +1 -1
- package/dist/interaction/adapter.js +80 -79
- package/dist/interaction/adapter.js.map +1 -1
- package/dist/lazy-sessions.d.ts +35 -0
- package/dist/lazy-sessions.d.ts.map +1 -0
- package/dist/lazy-sessions.js +98 -0
- package/dist/lazy-sessions.js.map +1 -0
- package/dist/quota/cache.d.ts +18 -0
- package/dist/quota/cache.d.ts.map +1 -0
- package/dist/quota/cache.js +32 -0
- package/dist/quota/cache.js.map +1 -0
- package/dist/quota/client.d.ts +30 -0
- package/dist/quota/client.d.ts.map +1 -0
- package/dist/quota/client.js +57 -0
- package/dist/quota/client.js.map +1 -0
- package/dist/quota/color.d.ts +58 -0
- package/dist/quota/color.d.ts.map +1 -0
- package/dist/quota/color.js +95 -0
- package/dist/quota/color.js.map +1 -0
- package/dist/quota/combined.d.ts +66 -0
- package/dist/quota/combined.d.ts.map +1 -0
- package/dist/quota/combined.js +179 -0
- package/dist/quota/combined.js.map +1 -0
- package/dist/quota/format.d.ts +105 -0
- package/dist/quota/format.d.ts.map +1 -0
- package/dist/quota/format.js +219 -0
- package/dist/quota/format.js.map +1 -0
- package/dist/quota/index.d.ts +23 -0
- package/dist/quota/index.d.ts.map +1 -0
- package/dist/quota/index.js +42 -0
- package/dist/quota/index.js.map +1 -0
- package/dist/quota/opencode-go/cache.d.ts +17 -0
- package/dist/quota/opencode-go/cache.d.ts.map +1 -0
- package/dist/quota/opencode-go/cache.js +31 -0
- package/dist/quota/opencode-go/cache.js.map +1 -0
- package/dist/quota/opencode-go/client.d.ts +28 -0
- package/dist/quota/opencode-go/client.d.ts.map +1 -0
- package/dist/quota/opencode-go/client.js +48 -0
- package/dist/quota/opencode-go/client.js.map +1 -0
- package/dist/quota/opencode-go/config.d.ts +37 -0
- package/dist/quota/opencode-go/config.d.ts.map +1 -0
- package/dist/quota/opencode-go/config.js +58 -0
- package/dist/quota/opencode-go/config.js.map +1 -0
- package/dist/quota/opencode-go/format.d.ts +36 -0
- package/dist/quota/opencode-go/format.d.ts.map +1 -0
- package/dist/quota/opencode-go/format.js +87 -0
- package/dist/quota/opencode-go/format.js.map +1 -0
- package/dist/quota/opencode-go/index.d.ts +30 -0
- package/dist/quota/opencode-go/index.d.ts.map +1 -0
- package/dist/quota/opencode-go/index.js +108 -0
- package/dist/quota/opencode-go/index.js.map +1 -0
- package/dist/quota/opencode-go/parse.d.ts +41 -0
- package/dist/quota/opencode-go/parse.d.ts.map +1 -0
- package/dist/quota/opencode-go/parse.js +75 -0
- package/dist/quota/opencode-go/parse.js.map +1 -0
- package/dist/quota/opencode-go/types.d.ts +48 -0
- package/dist/quota/opencode-go/types.d.ts.map +1 -0
- package/dist/quota/opencode-go/types.js +11 -0
- package/dist/quota/opencode-go/types.js.map +1 -0
- package/dist/quota/parse.d.ts +33 -0
- package/dist/quota/parse.d.ts.map +1 -0
- package/dist/quota/parse.js +200 -0
- package/dist/quota/parse.js.map +1 -0
- package/dist/quota/types.d.ts +72 -0
- package/dist/quota/types.d.ts.map +1 -0
- package/dist/quota/types.js +10 -0
- package/dist/quota/types.js.map +1 -0
- package/dist/remote/broadcast.d.ts +47 -0
- package/dist/remote/broadcast.d.ts.map +1 -0
- package/dist/remote/broadcast.js +121 -0
- package/dist/remote/broadcast.js.map +1 -0
- package/dist/remote/config.d.ts +32 -0
- package/dist/remote/config.d.ts.map +1 -0
- package/dist/remote/config.js +65 -0
- package/dist/remote/config.js.map +1 -0
- package/dist/remote/endpoint.d.ts +30 -0
- package/dist/remote/endpoint.d.ts.map +1 -0
- package/dist/remote/endpoint.js +213 -0
- package/dist/remote/endpoint.js.map +1 -0
- package/dist/remote/hub-server.d.ts +41 -0
- package/dist/remote/hub-server.d.ts.map +1 -0
- package/dist/remote/hub-server.js +346 -0
- package/dist/remote/hub-server.js.map +1 -0
- package/dist/server.d.ts +139 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +179 -4
- package/dist/server.js.map +1 -1
- package/dist/tasks-index.d.ts +14 -4
- package/dist/tasks-index.d.ts.map +1 -1
- package/dist/tasks-index.js +145 -46
- package/dist/tasks-index.js.map +1 -1
- package/dist/translators/event-translator.d.ts +33 -0
- package/dist/translators/event-translator.d.ts.map +1 -1
- package/dist/translators/event-translator.js +101 -0
- package/dist/translators/event-translator.js.map +1 -1
- package/dist/translators/index.d.ts +1 -1
- package/dist/translators/index.d.ts.map +1 -1
- package/dist/translators/index.js +1 -1
- package/dist/translators/index.js.map +1 -1
- package/dist/translators/projection-differ.d.ts +8 -0
- package/dist/translators/projection-differ.d.ts.map +1 -1
- package/dist/translators/projection-differ.js +19 -9
- package/dist/translators/projection-differ.js.map +1 -1
- package/dist/translators/tool-helpers.d.ts +28 -0
- package/dist/translators/tool-helpers.d.ts.map +1 -1
- package/dist/translators/tool-helpers.js +104 -0
- package/dist/translators/tool-helpers.js.map +1 -1
- package/dist/translators/types.d.ts +38 -1
- package/dist/translators/types.d.ts.map +1 -1
- package/dist/translators/types.js.map +1 -1
- package/dist/utils.d.ts +36 -7
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +69 -5
- package/dist/utils.js.map +1 -1
- package/docs/ARCHITECTURE.md +130 -40
- package/docs/BACKLOG.md +59 -0
- package/docs/DEVELOPMENT.md +31 -0
- package/docs/PROTOCOL.md +328 -23
- package/docs/REMOTE-CLIENTS.md +260 -0
- package/docs/REPLAY-GUIDE.md +131 -0
- package/docs/TROUBLESHOOTING.md +131 -14
- package/docs/adr/0001-bridge-lifetime-follows-primary-client.md +14 -0
- package/docs/adr/0002-stateless-hub-over-per-bridge-acp-endpoints.md +23 -0
- package/docs/adr/0003-tail-replay-meta-and-cursor-pagination.md +40 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/proposals/0001-tail-session-replay.md +136 -0
- package/docs/proposals/0002-plan-quota-usage.md +81 -0
- package/package.json +10 -6
package/dist/handlers/session.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session lifecycle handlers: initialize, new, list, resume, load, prompt, cancel.
|
|
3
3
|
*
|
|
4
|
-
* These map ACP session methods to ZCode app-server calls. `session/
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* These map ACP session methods to ZCode app-server calls. `session/new` is
|
|
5
|
+
* lazy: it returns a placeholder id and defers zcode `session/create` to the
|
|
6
|
+
* session's first use (`ensureRealSession`), so an editor startup that never
|
|
7
|
+
* prompts leaves no empty session in the backend or the App's task index.
|
|
8
|
+
* `session/prompt` runs the event-driven turn loop (subscribe-before-send
|
|
9
|
+
* ordering, no-progress timeout, stall reconciliation). ZCode events are
|
|
10
|
+
* translated via EventTranslator and dispatched as ACP `session/update`
|
|
11
|
+
* notifications.
|
|
8
12
|
*/
|
|
9
13
|
import process from "node:process";
|
|
10
14
|
import { randomUUID } from "node:crypto";
|
|
@@ -12,56 +16,176 @@ import { RequestError } from "@agentclientprotocol/sdk";
|
|
|
12
16
|
import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
|
|
13
17
|
import { buildModes, buildConfigOptions } from "../config/options.js";
|
|
14
18
|
import { emitInitialUsage } from "../config/model-cache.js";
|
|
19
|
+
import { buildProviderRegistry } from "../config/provider-registry.js";
|
|
15
20
|
import { buildResumeRuntimeModel } from "../config/runtime-model.js";
|
|
16
|
-
import {
|
|
21
|
+
import { lookupLazySession, recordMaterializedSession, rememberLazySession, } from "../lazy-sessions.js";
|
|
22
|
+
import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, isTransientTurnError, ProjectionDiffer, } from "../translators/index.js";
|
|
17
23
|
import { log, warn } from "../utils.js";
|
|
18
24
|
import { dispatchEvent } from "./dispatch.js";
|
|
19
|
-
import { sendSessionUpdate, sendTextChunk } from "./io.js";
|
|
25
|
+
import { sendSessionUpdate, sendTextChunk, withReplayBatch } from "./io.js";
|
|
26
|
+
import { fetchMessages, fullSlice, readTailLimit, replayMessages, sliceTail } from "./replay.js";
|
|
20
27
|
import { handleServerRequests } from "./server-requests.js";
|
|
21
28
|
/** Workspace descriptor used in session create/resume calls. */
|
|
22
29
|
function workspaceFor(cwd) {
|
|
23
30
|
const p = cwd || process.cwd();
|
|
24
31
|
return { workspacePath: p, workspaceKey: p };
|
|
25
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Push the provider registry to the backend so third-party providers (those in
|
|
35
|
+
* config.json) are recognised. The V4 backend doesn't auto-load them from
|
|
36
|
+
* config.json — without this RPC a session switching to a third-party model
|
|
37
|
+
* fails with `provider_not_configured`. Best-effort: failures are logged, not
|
|
38
|
+
* thrown, so a registry push problem never blocks session creation.
|
|
39
|
+
*/
|
|
40
|
+
async function syncProviderRegistry(server, cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const registry = buildProviderRegistry();
|
|
43
|
+
const resp = await server
|
|
44
|
+
.ensureBackend()
|
|
45
|
+
.request(server.nextId(), "workspace/updateProviderRegistry", { workspace: workspaceFor(cwd), registry }, 10000);
|
|
46
|
+
if (resp.error) {
|
|
47
|
+
warn(`provider-registry: sync failed: ${resp.error.message}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
log("provider-registry: synced to backend");
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
warn(`provider-registry: sync threw (${e instanceof Error ? e.message : String(e)})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
26
56
|
/** Convert a millisecond timestamp to ISO 8601 (for session list). */
|
|
27
57
|
function toIso(ms) {
|
|
28
58
|
if (typeof ms !== "number")
|
|
29
59
|
return undefined;
|
|
30
60
|
return new Date(ms).toISOString();
|
|
31
61
|
}
|
|
32
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* `session/new` → local placeholder id. The real zcode `session/create` is
|
|
64
|
+
* deferred to first use (`ensureRealSession`) so an editor startup that never
|
|
65
|
+
* sends a message leaves no empty session in the backend or the App's task
|
|
66
|
+
* index. The created session uses mode yolo (hardcoded).
|
|
67
|
+
*/
|
|
33
68
|
export async function newSession(server, params) {
|
|
34
|
-
const backend = server.ensureBackend();
|
|
35
69
|
const cwd = params.cwd ?? process.cwd();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
traceId: session.traceId,
|
|
56
|
-
});
|
|
57
|
-
const modes = await buildModes(server, sid);
|
|
58
|
-
server.lastMode.set(sid, modes.currentModeId);
|
|
70
|
+
// Placeholder id — the client addresses this session with it until the
|
|
71
|
+
// backend session materializes; never shown in session/list.
|
|
72
|
+
const acpSid = randomUUID();
|
|
73
|
+
server.pendingSessions.set(acpSid, { cwd, mcpServers: params.mcpServers });
|
|
74
|
+
// Persists past materialization (pendingSessions is cleared on first use) so
|
|
75
|
+
// the remote discovery payload can still label the workspace.
|
|
76
|
+
server.sessionCwds.set(acpSid, cwd);
|
|
77
|
+
// Durable alias so the placeholder survives a bridge restart and session/
|
|
78
|
+
// resume can still resolve it (best-effort; failures are swallowed inside
|
|
79
|
+
// the store).
|
|
80
|
+
rememberLazySession(acpSid, cwd);
|
|
81
|
+
// Only freshly-created sessions are eligible for auto-title on first
|
|
82
|
+
// end_turn; resumed/loaded sessions already have a title and must keep it.
|
|
83
|
+
server.titleEligibleSessions.add(acpSid);
|
|
84
|
+
log(`session/new (lazy) → ${acpSid} cwd=${cwd}`);
|
|
85
|
+
// No backend RPC yet: modes/configOptions are built from defaults (the
|
|
86
|
+
// pending session's real values arrive via updates once materialized).
|
|
87
|
+
const modes = await buildModes(server, null);
|
|
88
|
+
server.lastMode.set(acpSid, modes.currentModeId);
|
|
59
89
|
return {
|
|
60
|
-
sessionId:
|
|
90
|
+
sessionId: acpSid,
|
|
61
91
|
modes,
|
|
62
|
-
configOptions: await buildConfigOptions(server,
|
|
92
|
+
configOptions: await buildConfigOptions(server, null),
|
|
63
93
|
};
|
|
64
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Materialize a lazy `session/new` placeholder into a real backend session on
|
|
97
|
+
* first use (prompt / set_config_option / extension methods). Idempotent:
|
|
98
|
+
* returns the existing mapping for already-created sessions, and concurrent
|
|
99
|
+
* first-uses share a single `session/create` via the pending entry's `creating`
|
|
100
|
+
* promise. Unknown ids throw.
|
|
101
|
+
*/
|
|
102
|
+
export async function ensureRealSession(server, acpSid) {
|
|
103
|
+
const existing = server.resolveSid(acpSid);
|
|
104
|
+
if (existing)
|
|
105
|
+
return existing;
|
|
106
|
+
let pending = server.pendingSessions.get(acpSid);
|
|
107
|
+
if (!pending) {
|
|
108
|
+
// Placeholder from a previous bridge lifetime: recover it from the durable
|
|
109
|
+
// store. A record that already carries a zcodeSid maps straight through
|
|
110
|
+
// (the backend session still exists — re-register the alias); one without
|
|
111
|
+
// re-hydrates the pending entry so the create path below runs.
|
|
112
|
+
const record = lookupLazySession(acpSid);
|
|
113
|
+
if (record?.zcodeSid) {
|
|
114
|
+
server.registerSession(acpSid, record.zcodeSid);
|
|
115
|
+
return record.zcodeSid;
|
|
116
|
+
}
|
|
117
|
+
if (record) {
|
|
118
|
+
pending = { cwd: record.cwd };
|
|
119
|
+
server.pendingSessions.set(acpSid, pending);
|
|
120
|
+
server.sessionCwds.set(acpSid, record.cwd);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!pending)
|
|
124
|
+
throw new Error(`session ${acpSid} not found`);
|
|
125
|
+
if (pending.creating)
|
|
126
|
+
return pending.creating;
|
|
127
|
+
// The create body runs synchronously up to its first await, so the `creating`
|
|
128
|
+
// promise is stored before any concurrent caller can observe the entry.
|
|
129
|
+
const creating = (async () => {
|
|
130
|
+
const backend = server.ensureBackend();
|
|
131
|
+
// Push the provider registry BEFORE session/create: the backend resolves
|
|
132
|
+
// the session's default model against the registry, and without the
|
|
133
|
+
// provider's reasoning/model definitions it falls back to the bare
|
|
134
|
+
// anthropic channel (2-state thought: enabled/disabled) instead of the
|
|
135
|
+
// real provider (max/high/low). Also covers third-party providers for
|
|
136
|
+
// later model switches (provider_not_configured). Best-effort — a failed
|
|
137
|
+
// push logs and continues, the session still works over the fallback.
|
|
138
|
+
await syncProviderRegistry(server, pending.cwd);
|
|
139
|
+
// Client-provided MCP servers (ACP session/new mcpServers) ride along
|
|
140
|
+
// when the lazy session materializes. The backend accepts the ACP array
|
|
141
|
+
// shape verbatim; the verified merge behaviour is additive (client
|
|
142
|
+
// entries appear next to the runtime's own local config). Same-name
|
|
143
|
+
// clash behaviour is the backend's own and unasserted here.
|
|
144
|
+
const createParams = {
|
|
145
|
+
workspace: workspaceFor(pending.cwd),
|
|
146
|
+
mode: "yolo",
|
|
147
|
+
};
|
|
148
|
+
if (pending.mcpServers && pending.mcpServers.length > 0) {
|
|
149
|
+
createParams.mcpServers = pending.mcpServers;
|
|
150
|
+
log(`session/create carrying ${pending.mcpServers.length} client MCP server(s)`);
|
|
151
|
+
}
|
|
152
|
+
const resp = await backend.request(server.nextId(), "session/create", createParams, 15000);
|
|
153
|
+
if (resp.error) {
|
|
154
|
+
throw new Error(`zcode create failed: ${resp.error.message ?? ""}`);
|
|
155
|
+
}
|
|
156
|
+
const result = (resp.result ?? {});
|
|
157
|
+
const session = result.session ?? {};
|
|
158
|
+
const sid = session.sessionId;
|
|
159
|
+
if (!sid)
|
|
160
|
+
throw new Error("zcode create returned no sessionId");
|
|
161
|
+
server.pendingSessions.delete(acpSid);
|
|
162
|
+
server.registerSession(acpSid, sid);
|
|
163
|
+
// Keep the durable alias in sync so a later bridge restart can still
|
|
164
|
+
// resume this session via the placeholder id.
|
|
165
|
+
recordMaterializedSession(acpSid, sid, pending.cwd);
|
|
166
|
+
log(`session/new ${acpSid} → created ${sid} (lazy, on first use)`);
|
|
167
|
+
server.ensureBackgroundListener(sid);
|
|
168
|
+
// Sync to the App's tasks-index.sqlite so the App UI shows this session.
|
|
169
|
+
// Best-effort; failures are logged inside upsertSessionTask and swallowed.
|
|
170
|
+
const { upsertSessionTask } = await import("../tasks-index.js");
|
|
171
|
+
void upsertSessionTask({
|
|
172
|
+
workspaceKey: pending.cwd,
|
|
173
|
+
taskId: sid,
|
|
174
|
+
title: session.title ?? "",
|
|
175
|
+
traceId: session.traceId,
|
|
176
|
+
});
|
|
177
|
+
return sid;
|
|
178
|
+
})();
|
|
179
|
+
pending.creating = creating;
|
|
180
|
+
try {
|
|
181
|
+
return await creating;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
// Reset the in-flight marker (on success the sessionMap short-circuits
|
|
185
|
+
// later calls; on failure this lets the next use retry the create).
|
|
186
|
+
pending.creating = undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
65
189
|
/** `session/list` → zcode `session/list`. */
|
|
66
190
|
export async function listSessions(server, params) {
|
|
67
191
|
const backend = server.ensureBackend();
|
|
@@ -82,36 +206,117 @@ export async function listSessions(server, params) {
|
|
|
82
206
|
log(`session/list → ${sessions.length} sessions`);
|
|
83
207
|
return { sessions };
|
|
84
208
|
}
|
|
85
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Adopt the backend's stored title for a loaded/resumed session.
|
|
211
|
+
*
|
|
212
|
+
* The prompt loop's auto-title only fires for freshly created sessions
|
|
213
|
+
* (`titleEligibleSessions`), so a session resumed across a bridge restart
|
|
214
|
+
* would otherwise appear title-less in the hub's discovery API — remote
|
|
215
|
+
* clients have no editor-side session storage to fall back on. The backend's
|
|
216
|
+
* session/list is the only title source for sessions born in a previous
|
|
217
|
+
* bridge lifetime. Best-effort: failures log and leave the session untitled.
|
|
218
|
+
*/
|
|
219
|
+
async function adoptStoredTitle(server, acpSid, zcodeSid) {
|
|
220
|
+
if (server.sessionTitles.has(acpSid))
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
const backend = server.ensureBackend();
|
|
224
|
+
const resp = await backend.request(server.nextId(), "session/list", {}, 15000);
|
|
225
|
+
if (resp.error)
|
|
226
|
+
return;
|
|
227
|
+
const result = (resp.result ?? {});
|
|
228
|
+
const hit = (result.sessions ?? []).find((s) => s.sessionId === zcodeSid);
|
|
229
|
+
if (hit?.title) {
|
|
230
|
+
server.sessionTitles.set(acpSid, hit.title);
|
|
231
|
+
server.touchSessionSummary(acpSid, hit.title);
|
|
232
|
+
log(`adopted stored title for ${acpSid.slice(0, 8)}: ${hit.title}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
log(`stored title lookup failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Resolve the backend session id for `session/resume` / `session/load`.
|
|
241
|
+
*
|
|
242
|
+
* A `session/new` placeholder has no backend counterpart until first use, yet
|
|
243
|
+
* the editor may resume it anyway (panel reopen, bridge restart) — resolving it
|
|
244
|
+
* here prevents an otherwise unavoidable "Session not found". Resolution order:
|
|
245
|
+
* 1. in-memory mapping → the session is already live in this subprocess;
|
|
246
|
+
* 2. pending placeholder → materialize it (an empty session, matching the
|
|
247
|
+
* pre-lazy behavior where a never-used session/new always resumed);
|
|
248
|
+
* 3. durable store → a placeholder from a previous bridge lifetime: with a
|
|
249
|
+
* recorded zcodeSid the backend session still exists but isn't loaded into
|
|
250
|
+
* this subprocess (the resume RPC is needed); without one, materialize
|
|
251
|
+
* fresh;
|
|
252
|
+
* 4. anything else (a real id from session/list, or a stale id) → pass
|
|
253
|
+
* through unchanged; genuinely missing sessions still error downstream.
|
|
254
|
+
*/
|
|
255
|
+
async function resolveResumeTarget(server, acpSid) {
|
|
256
|
+
const mapped = server.resolveSid(acpSid);
|
|
257
|
+
if (mapped)
|
|
258
|
+
return { zcodeSid: mapped, alreadyLive: true };
|
|
259
|
+
if (server.pendingSessions.has(acpSid)) {
|
|
260
|
+
return { zcodeSid: await ensureRealSession(server, acpSid), alreadyLive: true };
|
|
261
|
+
}
|
|
262
|
+
const record = lookupLazySession(acpSid);
|
|
263
|
+
if (record) {
|
|
264
|
+
// ensureRealSession recovers the record: with a zcodeSid it re-registers
|
|
265
|
+
// the alias (no create), without one it materializes a fresh session.
|
|
266
|
+
return {
|
|
267
|
+
zcodeSid: await ensureRealSession(server, acpSid),
|
|
268
|
+
alreadyLive: !record.zcodeSid,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return { zcodeSid: acpSid, alreadyLive: false };
|
|
272
|
+
}
|
|
273
|
+
/** `session/resume` → zcode `session/resume` (with runtimeModel overlay). */
|
|
86
274
|
export async function resumeSession(server, params, cx) {
|
|
87
|
-
const
|
|
88
|
-
const targetSid = params.sessionId;
|
|
275
|
+
const acpSid = params.sessionId;
|
|
89
276
|
const cwd = params.cwd ?? process.cwd();
|
|
90
|
-
if (!
|
|
277
|
+
if (!acpSid)
|
|
91
278
|
throw new Error("sessionId required");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
279
|
+
// Lazy placeholders (session/new) resolve to their real backend session
|
|
280
|
+
// here; alreadyLive targets skip the resume RPC because the session is live
|
|
281
|
+
// in this backend subprocess.
|
|
282
|
+
const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
|
|
283
|
+
if (!alreadyLive) {
|
|
284
|
+
// runtimeModel overlay: a resumed session may carry a stale/revoked model in
|
|
285
|
+
// its history → send fails with "历史模型不可用". Overlaying the current
|
|
286
|
+
// enabled provider redirects the session onto a working model. The overlay
|
|
287
|
+
// deliberately carries NO apiKey (the backend's schema rejects it; it resolves
|
|
288
|
+
// auth from its own config/OAuth store).
|
|
289
|
+
const zcParams = {
|
|
290
|
+
sessionId: zcodeSid,
|
|
291
|
+
workspace: workspaceFor(cwd),
|
|
292
|
+
};
|
|
293
|
+
// ACP session/resume may also carry mcpServers; the backend's resume
|
|
294
|
+
// schema accepts the same array shape (verified: an unknown key would be
|
|
295
|
+
// rejected before the session lookup).
|
|
296
|
+
if (params.mcpServers && params.mcpServers.length > 0) {
|
|
297
|
+
zcParams.mcpServers = params.mcpServers;
|
|
298
|
+
}
|
|
299
|
+
const runtimeModel = buildResumeRuntimeModel();
|
|
300
|
+
if (runtimeModel !== null)
|
|
301
|
+
zcParams.runtimeModel = runtimeModel;
|
|
302
|
+
// Push the provider registry BEFORE resume: a resumed session may carry a
|
|
303
|
+
// third-party model in its history, and the backend needs the provider
|
|
304
|
+
// registered to even process the resume turn.
|
|
305
|
+
await syncProviderRegistry(server, cwd);
|
|
306
|
+
await resumeBackendSession(server, zcParams);
|
|
307
|
+
}
|
|
308
|
+
server.registerSession(acpSid, zcodeSid);
|
|
309
|
+
log(`session/resume -> ${zcodeSid}`);
|
|
310
|
+
server.ensureBackgroundListener(zcodeSid);
|
|
311
|
+
await adoptStoredTitle(server, acpSid, zcodeSid);
|
|
107
312
|
// Initial usage_update so the editor shows the context bar immediately for a
|
|
108
313
|
// resumed session (mirrors Python _on_session_resume → _emit_initial_usage).
|
|
109
|
-
await emitInitialUsage(server, cx,
|
|
110
|
-
const modes = await buildModes(server,
|
|
111
|
-
server.lastMode.set(
|
|
314
|
+
await emitInitialUsage(server, cx, acpSid, zcodeSid, getOrCreateDiffer(server, zcodeSid));
|
|
315
|
+
const modes = await buildModes(server, zcodeSid);
|
|
316
|
+
server.lastMode.set(acpSid, modes.currentModeId);
|
|
112
317
|
return {
|
|
113
318
|
modes,
|
|
114
|
-
configOptions: await buildConfigOptions(server,
|
|
319
|
+
configOptions: await buildConfigOptions(server, zcodeSid),
|
|
115
320
|
};
|
|
116
321
|
}
|
|
117
322
|
/**
|
|
@@ -119,113 +324,91 @@ export async function resumeSession(server, params, cx) {
|
|
|
119
324
|
* `session/update` notifications (text/reasoning/简化 tool_call).
|
|
120
325
|
*/
|
|
121
326
|
export async function loadSession(server, params, cx) {
|
|
122
|
-
const
|
|
123
|
-
const targetSid = params.sessionId;
|
|
327
|
+
const acpSid = params.sessionId;
|
|
124
328
|
const cwd = params.cwd ?? process.cwd();
|
|
125
|
-
if (!
|
|
329
|
+
if (!acpSid)
|
|
126
330
|
throw new Error("sessionId required");
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const role = info.role;
|
|
144
|
-
const mid = info.id ?? `hist_${randomUUID().slice(0, 12)}`;
|
|
145
|
-
for (const p of m.parts ?? []) {
|
|
146
|
-
if (!p || typeof p !== "object")
|
|
147
|
-
continue;
|
|
148
|
-
const ptype = p.type;
|
|
149
|
-
if (ptype === "text") {
|
|
150
|
-
const text = p.text ?? "";
|
|
151
|
-
if (!text)
|
|
152
|
-
continue;
|
|
153
|
-
const sessionUpdate = role === "user" ? "user_message_chunk" : "agent_message_chunk";
|
|
154
|
-
await sendSessionUpdate(cx, targetSid, {
|
|
155
|
-
sessionUpdate,
|
|
156
|
-
content: { type: "text", text },
|
|
157
|
-
messageId: mid,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
else if (ptype === "reasoning") {
|
|
161
|
-
const rp = p;
|
|
162
|
-
const text = rp.text ?? rp.content ?? "";
|
|
163
|
-
if (text) {
|
|
164
|
-
await sendSessionUpdate(cx, targetSid, {
|
|
165
|
-
sessionUpdate: "agent_thought_chunk",
|
|
166
|
-
content: { type: "text", text },
|
|
167
|
-
messageId: `thought_${mid}`,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
else if (ptype === "tool") {
|
|
172
|
-
const tp = p;
|
|
173
|
-
const title = tp.title ?? tp.tool ?? "tool call";
|
|
174
|
-
const histToolName = tp.tool ?? "";
|
|
175
|
-
const update = {
|
|
176
|
-
sessionUpdate: "tool_call",
|
|
177
|
-
toolCallId: tp.id ?? `histtool_${randomUUID().slice(0, 8)}`,
|
|
178
|
-
title,
|
|
179
|
-
kind: "other",
|
|
180
|
-
status: tp.status ?? "completed",
|
|
181
|
-
...(histToolName ? { _meta: { claudeCode: { toolName: histToolName } } } : {}),
|
|
182
|
-
};
|
|
183
|
-
await sendSessionUpdate(cx, targetSid, update);
|
|
184
|
-
}
|
|
185
|
-
// patch / step-start / other: skipped (history replay focuses on text + tool summary)
|
|
186
|
-
}
|
|
187
|
-
replayed += 1;
|
|
331
|
+
// Same placeholder resolution as resumeSession; alreadyLive targets skip the
|
|
332
|
+
// backend resume RPC (the session is live in this subprocess).
|
|
333
|
+
const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
|
|
334
|
+
if (!alreadyLive) {
|
|
335
|
+
const zcParams = {
|
|
336
|
+
sessionId: zcodeSid,
|
|
337
|
+
workspace: workspaceFor(cwd),
|
|
338
|
+
};
|
|
339
|
+
const runtimeModel = buildResumeRuntimeModel();
|
|
340
|
+
if (runtimeModel !== null)
|
|
341
|
+
zcParams.runtimeModel = runtimeModel;
|
|
342
|
+
// Push the provider registry BEFORE resume: a loaded session may carry a
|
|
343
|
+
// third-party model in its history, and the backend needs the provider
|
|
344
|
+
// registered to process it.
|
|
345
|
+
await syncProviderRegistry(server, cwd);
|
|
346
|
+
await resumeBackendSession(server, zcParams);
|
|
188
347
|
}
|
|
189
|
-
|
|
348
|
+
server.registerSession(acpSid, zcodeSid);
|
|
349
|
+
log(`session/load → ${zcodeSid}`);
|
|
350
|
+
server.ensureBackgroundListener(zcodeSid);
|
|
351
|
+
await adoptStoredTitle(server, acpSid, zcodeSid);
|
|
352
|
+
const messages = await fetchMessages(server, zcodeSid);
|
|
353
|
+
// Tail replay (Proposal 0001): a `_meta.zcode.limit` replays only the last
|
|
354
|
+
// N messages aligned to turn boundaries — the full replay stays the default
|
|
355
|
+
// for editors that send no `_meta` (Zed path unchanged).
|
|
356
|
+
const limit = readTailLimit(params);
|
|
357
|
+
const slice = limit === null ? fullSlice(messages) : sliceTail(messages, limit);
|
|
358
|
+
await withReplayBatch(acpSid, () => replayMessages(cx, acpSid, slice.batch));
|
|
359
|
+
log(`session/load: replayed ${slice.meta.replayedMessages} messages` +
|
|
360
|
+
`${limit === null ? "" : ` (tail limit ${limit}, total ${slice.meta.totalMessages})`}`);
|
|
190
361
|
// Replay the existing todo list as an initial plan so a loaded session shows
|
|
191
362
|
// its todos immediately (filter to PlanUpdate only — text/tools were already
|
|
192
363
|
// replayed above and the differ hasn't mark_seen'd this history).
|
|
193
364
|
try {
|
|
194
|
-
const snapshot = await buildSnapshot(server,
|
|
195
|
-
const loadDiffer = getOrCreateDiffer(server,
|
|
365
|
+
const snapshot = await buildSnapshot(server, zcodeSid);
|
|
366
|
+
const loadDiffer = getOrCreateDiffer(server, zcodeSid);
|
|
196
367
|
const planEvents = loadDiffer.diff(snapshot).filter((e) => e.kind === "PlanUpdate");
|
|
197
368
|
for (const iev of planEvents) {
|
|
198
|
-
await dispatchEvent(server, cx,
|
|
369
|
+
await dispatchEvent(server, cx, acpSid, iev, `load_${randomUUID().slice(0, 8)}`);
|
|
199
370
|
}
|
|
200
371
|
}
|
|
201
372
|
catch (e) {
|
|
202
373
|
log(`session/load: initial plan read failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
203
374
|
}
|
|
204
375
|
// Initial usage_update so the editor shows the context bar immediately.
|
|
205
|
-
await emitInitialUsage(server, cx,
|
|
206
|
-
const modes = await buildModes(server,
|
|
207
|
-
server.lastMode.set(
|
|
208
|
-
|
|
376
|
+
await emitInitialUsage(server, cx, acpSid, zcodeSid, getOrCreateDiffer(server, zcodeSid));
|
|
377
|
+
const modes = await buildModes(server, zcodeSid);
|
|
378
|
+
server.lastMode.set(acpSid, modes.currentModeId);
|
|
379
|
+
const result = {
|
|
209
380
|
modes,
|
|
210
|
-
configOptions: await buildConfigOptions(server,
|
|
381
|
+
configOptions: await buildConfigOptions(server, zcodeSid),
|
|
382
|
+
// Additive replay metadata — the anchor for load_earlier pagination.
|
|
383
|
+
replayMeta: slice.meta,
|
|
211
384
|
};
|
|
385
|
+
return result;
|
|
212
386
|
}
|
|
213
387
|
/** `session/prompt` → subscribe-before-send, run the event-driven turn loop. */
|
|
214
388
|
export async function prompt(server, params, cx, requestId) {
|
|
215
389
|
const backend = server.ensureBackend();
|
|
216
|
-
|
|
217
|
-
if (!zcodeSid)
|
|
218
|
-
throw new Error(`session ${params.sessionId} not found`);
|
|
219
|
-
// Extract prompt text from ACP ContentBlock[].
|
|
390
|
+
// Extract prompt text + image attachments from ACP ContentBlock[].
|
|
220
391
|
const text = extractPromptText(params.prompt);
|
|
221
|
-
|
|
392
|
+
const attachments = extractAttachments(params.prompt);
|
|
393
|
+
// A prompt is valid if it has text OR at least one image attachment (a user
|
|
394
|
+
// may drag in an image with no accompanying text).
|
|
395
|
+
if (!text && attachments.length === 0)
|
|
222
396
|
throw new Error("empty prompt");
|
|
397
|
+
// Materialize a lazy session/new placeholder on first use. Placed after the
|
|
398
|
+
// empty-prompt check so an invalid request doesn't create a backend session.
|
|
399
|
+
const zcodeSid = await ensureRealSession(server, params.sessionId);
|
|
223
400
|
// Slash-command interception: dispatches directly to ZCode methods and
|
|
224
|
-
// returns end_turn without entering the turn loop.
|
|
225
|
-
|
|
401
|
+
// returns end_turn without entering the turn loop. Known passthrough
|
|
402
|
+
// commands and unknown /x both return null for the normal turn loop.
|
|
403
|
+
const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js");
|
|
226
404
|
const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
|
|
227
405
|
if (intercepted)
|
|
228
406
|
return intercepted;
|
|
407
|
+
// Wire text for the backend: unknown `/x` prompts (not advertised commands)
|
|
408
|
+
// are neutralized so the backend's command resolver never sees them — an
|
|
409
|
+
// unresolvable name can hard-fail the turn. Known commands pass through
|
|
410
|
+
// unchanged. The title/auto-compact paths below keep using the raw `text`.
|
|
411
|
+
const sendText = neutralizeSlashText(text);
|
|
229
412
|
// Register self + preempt others under a per-session lock. The lock
|
|
230
413
|
// serializes the critical section so that two concurrent prompts (B, C) for
|
|
231
414
|
// the same session can't both miss each other and register at once: C waits
|
|
@@ -236,9 +419,15 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
236
419
|
zcodeSid,
|
|
237
420
|
cancelled: false,
|
|
238
421
|
};
|
|
239
|
-
|
|
422
|
+
// True when this send cancelled another in-flight prompt (preempt/stop).
|
|
423
|
+
// Drives the turn-attribution gate: only a preempted prompt can see leftover
|
|
424
|
+
// events from a prior turn in its listener queue; without preemption any
|
|
425
|
+
// events before this turn's turn.started belong to a backend-owned turn
|
|
426
|
+
// (e.g. auto-resumed after compaction) that this send was steered into.
|
|
427
|
+
let preempted = false;
|
|
428
|
+
await withPreemptLock(server, zcodeSid, async () => {
|
|
240
429
|
server.pendingTurns.set(requestId, turn);
|
|
241
|
-
|
|
430
|
+
preempted = preemptInFlightTurn(server, zcodeSid, requestId);
|
|
242
431
|
});
|
|
243
432
|
const listener = new EventStreamListener(backend, zcodeSid);
|
|
244
433
|
const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
|
|
@@ -248,45 +437,180 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
248
437
|
const baselineMsgs = await fetchMessages(server, zcodeSid);
|
|
249
438
|
differ.markSeen(baselineMsgs);
|
|
250
439
|
// Subscribe BEFORE send so we don't lose early turn.completed on short turns.
|
|
251
|
-
|
|
252
|
-
|
|
440
|
+
// subscribe() throws on failure, surfacing the backend's real error (reader
|
|
441
|
+
// dead, timeout, pipe broken, method-not-found on old CLI, session error) so
|
|
442
|
+
// the cause is distinguishable. Clean up the pending turn before propagating
|
|
443
|
+
// — this call site is outside the try/finally below.
|
|
444
|
+
let snapshot;
|
|
445
|
+
try {
|
|
446
|
+
snapshot = await listener.subscribe(() => server.nextId());
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
253
449
|
server.pendingTurns.delete(requestId);
|
|
254
|
-
throw
|
|
450
|
+
throw e;
|
|
255
451
|
}
|
|
452
|
+
// subscribe() requests includeSnapshot:false (it only needs the eventSeq
|
|
453
|
+
// watermark to arm the event stream), so `snapshot` is an empty fallback.
|
|
454
|
+
// The real projection baseline comes from fetchMessages + differ.markSeen
|
|
455
|
+
// above. Kept as a binding only so the call fits the Promise-returning shape.
|
|
456
|
+
void snapshot;
|
|
256
457
|
backend.registerEventListener(zcodeSid, listener);
|
|
257
|
-
const chunkMsgId = randomUUID();
|
|
258
458
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
459
|
+
// Transient turn failures (e.g. provider network blips surfaced as
|
|
460
|
+
// turn.failed with cause code model_request_failed) are retried by
|
|
461
|
+
// re-sending the prompt and re-running the event loop, instead of
|
|
462
|
+
// surfacing a hard error that stops the session. Non-transient failures
|
|
463
|
+
// (send rejected, non-transient turn error) propagate immediately. After
|
|
464
|
+
// exhausting retries on a transient error we degrade gracefully: emit a
|
|
465
|
+
// user-visible message and return end_turn so the session stays usable.
|
|
466
|
+
// 1 initial attempt + 5 retries. Backoff grows exponentially then caps so
|
|
467
|
+
// later retries don't keep stretching: 1s, 2s, 4s, 4s, 4s.
|
|
468
|
+
const MAX_TURN_ATTEMPTS = 6;
|
|
469
|
+
const MAX_BACKOFF_MS = 4000;
|
|
470
|
+
const backoffMs = (attempt) => Math.min(1000 * 2 ** (attempt - 1), MAX_BACKOFF_MS);
|
|
471
|
+
let lastTurnError = null;
|
|
472
|
+
for (let attempt = 1; attempt <= MAX_TURN_ATTEMPTS; attempt++) {
|
|
473
|
+
if (attempt > 1) {
|
|
474
|
+
// A prior transient turn ended the backend turn; before re-sending,
|
|
475
|
+
// reconcile the differ baseline so the retried turn's new messages
|
|
476
|
+
// aren't treated as already-seen, surface a retry hint, then back off.
|
|
477
|
+
if (turn.cancelled) {
|
|
478
|
+
stopBackendTurn(server, zcodeSid);
|
|
479
|
+
return { stopReason: "cancelled" };
|
|
480
|
+
}
|
|
481
|
+
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
482
|
+
await sendTextChunk(cx, params.sessionId, `[网络异常,正在重试 (${attempt - 1}/${MAX_TURN_ATTEMPTS - 1})…]`, randomUUID());
|
|
483
|
+
log(` [retry] transient turn failed, re-sending (attempt ${attempt}/${MAX_TURN_ATTEMPTS})`);
|
|
484
|
+
await sleep(backoffMs(attempt - 1));
|
|
485
|
+
}
|
|
486
|
+
const chunkMsgId = randomUUID();
|
|
487
|
+
// Send the prompt, retrying while the backend reports it's still busy.
|
|
488
|
+
// The backend's prompt lock is the single authoritative readiness signal:
|
|
489
|
+
// a rejected send (code 1308 "prompt is running") means a previous turn
|
|
490
|
+
// (cancelled, preempted, or still finalising) hasn't released the lock
|
|
491
|
+
// yet. Rather than guessing when the backend is ready — or blocking on a
|
|
492
|
+
// local shadow flag — we retry with a fixed delay until the backend
|
|
493
|
+
// accepts. This covers the preempt path (new prompt interrupting an
|
|
494
|
+
// in-flight one) and the stop-recovery window after a manual cancel.
|
|
495
|
+
const SEND_RETRY_INTERVAL_MS = 500;
|
|
496
|
+
const SEND_RETRY_TIMEOUT_MS = 30_000;
|
|
497
|
+
const sendParams = attachments.length > 0
|
|
498
|
+
? { sessionId: zcodeSid, content: sendText, attachments }
|
|
499
|
+
: { sessionId: zcodeSid, content: sendText };
|
|
500
|
+
const sendT0 = Date.now();
|
|
501
|
+
let sendAttempt = 0;
|
|
502
|
+
while (true) {
|
|
503
|
+
if (turn.cancelled) {
|
|
504
|
+
stopBackendTurn(server, zcodeSid);
|
|
505
|
+
return { stopReason: "cancelled" };
|
|
506
|
+
}
|
|
507
|
+
sendAttempt++;
|
|
508
|
+
// Wait before sending when a recent cancel/preempt makes a busy reject
|
|
509
|
+
// likely — right after stop the backend is in its recovery window and
|
|
510
|
+
// will reject an immediate send. On the first attempt with no recent
|
|
511
|
+
// cancel, send immediately so normal prompts aren't delayed.
|
|
512
|
+
const recentCancel = server.lastCancelledAt.get(zcodeSid);
|
|
513
|
+
const expectBusy = sendAttempt > 1 ||
|
|
514
|
+
(recentCancel !== undefined && Date.now() - recentCancel < SEND_RETRY_TIMEOUT_MS);
|
|
515
|
+
if (expectBusy) {
|
|
516
|
+
await sleep(SEND_RETRY_INTERVAL_MS);
|
|
517
|
+
if (turn.cancelled) {
|
|
518
|
+
stopBackendTurn(server, zcodeSid);
|
|
519
|
+
return { stopReason: "cancelled" };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const sendResp = await backend.request(server.nextId(), "session/send", sendParams, 15000);
|
|
523
|
+
if (!sendResp.error) {
|
|
524
|
+
const accepted = (sendResp.result ?? {});
|
|
525
|
+
if (accepted.accepted)
|
|
526
|
+
break; // backend took it → turn starts
|
|
527
|
+
throw new Error("zcode send not accepted");
|
|
528
|
+
}
|
|
529
|
+
const sendErrCode = sendResp.error.code;
|
|
530
|
+
const sendErrMsg = (sendResp.error.message ?? "").toLowerCase();
|
|
531
|
+
const isBusy = sendErrCode === 1308 ||
|
|
532
|
+
sendErrMsg.includes("prompt is running") ||
|
|
533
|
+
sendErrMsg.includes("already running");
|
|
534
|
+
if (!isBusy) {
|
|
535
|
+
// Non-busy error (auth, malformed, etc.) — don't retry, surface it.
|
|
536
|
+
throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`);
|
|
537
|
+
}
|
|
538
|
+
if (Date.now() - sendT0 > SEND_RETRY_TIMEOUT_MS) {
|
|
539
|
+
throw new Error(`zcode send failed: backend still busy after ${Math.round(SEND_RETRY_TIMEOUT_MS / 1000)}s (${sendResp.error.message ?? ""})`);
|
|
540
|
+
}
|
|
541
|
+
log(` [send] backend busy (${sendResp.error.message ?? ""}), retrying in ${SEND_RETRY_INTERVAL_MS}ms`);
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
// Event-driven turn loop: translate events via EventTranslator + dispatch.
|
|
545
|
+
const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn, preempted);
|
|
546
|
+
// Session title: set once on the first end_turn, but ONLY for freshly
|
|
547
|
+
// created sessions. Resumed/loaded sessions already carry a title from
|
|
548
|
+
// their history and must not be overwritten by the first post-load
|
|
549
|
+
// message. sessionTitles enforces set-once within a session;
|
|
550
|
+
// titleEligibleSessions gates which sessions are titled at all.
|
|
551
|
+
if (result.stopReason === "end_turn" &&
|
|
552
|
+
server.titleEligibleSessions.has(params.sessionId) &&
|
|
553
|
+
!server.sessionTitles.has(params.sessionId)) {
|
|
554
|
+
// Title = first non-empty line of the prompt, truncated to 80 chars.
|
|
555
|
+
// Multi-line prompts must not leak newlines into the session title.
|
|
556
|
+
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
|
|
557
|
+
const title = text
|
|
558
|
+
.split(/\r\n|\r|\n/)
|
|
559
|
+
.map((l) => l.trim())
|
|
560
|
+
.find((l) => l.length > 0)
|
|
561
|
+
?.slice(0, 80) ?? text.slice(0, 80);
|
|
562
|
+
server.sessionTitles.set(params.sessionId, title);
|
|
563
|
+
server.touchSessionSummary(params.sessionId, title);
|
|
564
|
+
const { updateSessionTitle } = await import("../tasks-index.js");
|
|
565
|
+
void updateSessionTitle(zcodeSid, title, text);
|
|
566
|
+
await sendSessionUpdate(cx, params.sessionId, {
|
|
567
|
+
sessionUpdate: "session_info_update",
|
|
568
|
+
title,
|
|
569
|
+
updatedAt: new Date().toISOString(),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
// Auto-compact: if context usage exceeds the threshold, compact before
|
|
573
|
+
// returning so the next prompt has room. Configured via
|
|
574
|
+
// ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
|
|
575
|
+
// disabled). Only on end_turn — cancelled/max_turn_requests skips
|
|
576
|
+
// compaction, as does a stall-recovered end_turn (the completion was
|
|
577
|
+
// inferred by the stall heuristic, not confirmed by turn.completed —
|
|
578
|
+
// compressing an in-flight task's context would destroy the work).
|
|
579
|
+
// Best-effort: failures are logged inside maybeAutoCompact, never thrown.
|
|
580
|
+
if (result.stopReason === "end_turn" && !turn.stallRecovered) {
|
|
581
|
+
const { maybeAutoCompact } = await import("../config/auto-compact.js");
|
|
582
|
+
await maybeAutoCompact(server, cx, params.sessionId, zcodeSid);
|
|
583
|
+
}
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
catch (e) {
|
|
587
|
+
// Only a transient TurnFailedError is retryable; everything else (send
|
|
588
|
+
// failures, non-transient turn errors, exhausted retries, cancellation)
|
|
589
|
+
// propagates to the caller.
|
|
590
|
+
if (e instanceof TurnFailedError &&
|
|
591
|
+
attempt < MAX_TURN_ATTEMPTS &&
|
|
592
|
+
!turn.cancelled &&
|
|
593
|
+
isTransientTurnError(e.turnError)) {
|
|
594
|
+
lastTurnError = e.turnError;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
throw e;
|
|
598
|
+
}
|
|
284
599
|
}
|
|
285
|
-
|
|
600
|
+
// All retries exhausted on a transient error → degrade gracefully. Keep the
|
|
601
|
+
// session usable so the user can resend the message instead of the editor
|
|
602
|
+
// surfacing a hard error and stopping. Skip auto-compact here: compaction
|
|
603
|
+
// after a failed turn is more likely to confuse state than help.
|
|
604
|
+
const errMsg = formatTurnError(lastTurnError) || "turn failed after retries";
|
|
605
|
+
await sendTextChunk(cx, params.sessionId, `[请求失败:${errMsg}。会话仍可用,请重新发送消息重试。]`, randomUUID());
|
|
606
|
+
return { stopReason: "end_turn" };
|
|
286
607
|
}
|
|
287
608
|
finally {
|
|
288
|
-
backend.unregisterEventListener(zcodeSid);
|
|
609
|
+
backend.unregisterEventListener(zcodeSid, listener);
|
|
289
610
|
server.pendingTurns.delete(requestId);
|
|
611
|
+
// Turn end = session activity — refresh the discovery summary timestamp
|
|
612
|
+
// regardless of outcome (end_turn, cancelled, retries exhausted).
|
|
613
|
+
server.touchSessionSummary(params.sessionId);
|
|
290
614
|
}
|
|
291
615
|
}
|
|
292
616
|
/**
|
|
@@ -294,12 +618,11 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
294
618
|
* resulting config_option_update (+ current_mode_update for mode).
|
|
295
619
|
*/
|
|
296
620
|
export async function setConfigOptionHandler(server, params, cx) {
|
|
297
|
-
const zcodeSid = server.resolveSid(params.sessionId);
|
|
298
|
-
if (!zcodeSid)
|
|
299
|
-
throw new Error(`session ${params.sessionId} not found`);
|
|
300
621
|
if (typeof params.value !== "string") {
|
|
301
622
|
throw new Error(`unsupported config value type: ${String(params.value)}`);
|
|
302
623
|
}
|
|
624
|
+
// Materialize a lazy session/new placeholder on first use.
|
|
625
|
+
const zcodeSid = await ensureRealSession(server, params.sessionId);
|
|
303
626
|
const { setConfigOption, emitConfigOptionUpdate } = await import("../config/options.js");
|
|
304
627
|
const result = await setConfigOption(server, zcodeSid, params.configId, params.value);
|
|
305
628
|
if (!result) {
|
|
@@ -309,32 +632,71 @@ export async function setConfigOptionHandler(server, params, cx) {
|
|
|
309
632
|
return { configOptions: options };
|
|
310
633
|
}
|
|
311
634
|
/**
|
|
312
|
-
* `session/cancel` →
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
635
|
+
* `session/cancel` → stop the in-flight turn immediately. Mirrors the ZCode
|
|
636
|
+
* App's stop button, which sends a stop command directly (there is no
|
|
637
|
+
* "cancel" concept on the client — only stop).
|
|
638
|
+
*
|
|
639
|
+
* We fire `session/stop` here instead of deferring it to the turn loop. The
|
|
640
|
+
* loop is blocked for seconds at a time behind awaits (handleServerRequests
|
|
641
|
+
* waiting on a permission popup; dispatchEvent running per-event; the
|
|
642
|
+
* tool-result path awaiting dispatchEditDiff/dispatchPlanIfChanged backend
|
|
643
|
+
* calls with up to 8s timeouts). A deferred stop only fires once the loop
|
|
644
|
+
* finishes whatever await it is stuck in, so the user's press of stop can lag
|
|
645
|
+
* by the full remaining await window — the turn visibly "keeps running".
|
|
646
|
+
* `session/stop` is fire-and-forget and fully idempotent (the backend no-ops
|
|
647
|
+
* on a session with no active turn, and on a turn already aborted), so firing
|
|
648
|
+
* it eagerly is safe; the loop's `stopSent` guard prevents a second send.
|
|
649
|
+
*
|
|
650
|
+
* `turn.cancelled` is still set so the turn loop switches to its silent-drain
|
|
651
|
+
* path (translate to detect turnDone, but discard every internal event — no
|
|
652
|
+
* text/tool/usage is pushed after the user stopped).
|
|
316
653
|
*/
|
|
317
654
|
export async function cancel(server, params) {
|
|
318
655
|
const zcodeSid = server.resolveSid(params.sessionId);
|
|
319
656
|
if (!zcodeSid)
|
|
320
657
|
return;
|
|
658
|
+
// Cancel ALL matching turns for this session (not just the first). While a
|
|
659
|
+
// prior turn is still finalising, pendingTurns holds both it and any newer
|
|
660
|
+
// prompt waiting on the backend's prompt lock; breaking on the first match
|
|
661
|
+
// could leave the live one running. The stopSent guard dedupes the backend
|
|
662
|
+
// stop call across turns and repeated cancels.
|
|
321
663
|
for (const [, turn] of server.pendingTurns) {
|
|
322
664
|
if (turn.zcodeSid === zcodeSid) {
|
|
323
665
|
turn.cancelled = true;
|
|
324
|
-
|
|
666
|
+
if (!turn.stopSent) {
|
|
667
|
+
stopBackendTurn(server, zcodeSid);
|
|
668
|
+
turn.stopSent = true;
|
|
669
|
+
}
|
|
670
|
+
// Record cancel time so a prompt arriving in the backend's ~20s
|
|
671
|
+
// model-connection recovery window can fast-fail instead of hanging.
|
|
672
|
+
server.lastCancelledAt.set(zcodeSid, Date.now());
|
|
325
673
|
}
|
|
326
674
|
}
|
|
327
675
|
log(`session/cancel → ${zcodeSid}`);
|
|
328
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Raised by `runEventTurn` when the backend emits `turn.failed`. Carries the
|
|
679
|
+
* structured error object (with its nested `cause`) so `prompt`'s retry loop
|
|
680
|
+
* can classify transient vs fatal via `isTransientTurnError`. The display
|
|
681
|
+
* message is derived from `formatTurnError` at construction time.
|
|
682
|
+
*/
|
|
683
|
+
class TurnFailedError extends Error {
|
|
684
|
+
turnError;
|
|
685
|
+
constructor(turnError) {
|
|
686
|
+
super(formatTurnError(turnError) || "turn failed");
|
|
687
|
+
this.name = "TurnFailedError";
|
|
688
|
+
this.turnError = turnError;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
329
691
|
/**
|
|
330
692
|
* Fire-and-forget `session/stop` to the backend. Mirrors Python's
|
|
331
693
|
* `_cancel_backend_turn`: send stop with an id (some backends route by id
|
|
332
694
|
* presence), never wait for a response, never throw.
|
|
333
695
|
*
|
|
334
|
-
* The
|
|
335
|
-
*
|
|
336
|
-
* prompt
|
|
337
|
-
*
|
|
696
|
+
* The turn-loop cancel site calls this once (guarded by turn.stopSent), then
|
|
697
|
+
* keeps looping until the backend emits turn.completed/turn.failed. The
|
|
698
|
+
* backend's prompt lock releases when ITS finalisation completes — that,
|
|
699
|
+
* not any bridge-side signal, is what the next prompt's send-retry waits on.
|
|
338
700
|
*/
|
|
339
701
|
function stopBackendTurn(server, zcodeSid) {
|
|
340
702
|
try {
|
|
@@ -354,9 +716,9 @@ function stopBackendTurn(server, zcodeSid) {
|
|
|
354
716
|
* entering its section sees this turn in its preempt scan. Without this lock,
|
|
355
717
|
* two near-simultaneous prompts could both scan before either registers.
|
|
356
718
|
*
|
|
357
|
-
* The body
|
|
358
|
-
*
|
|
359
|
-
* this lock — only registration + preempt
|
|
719
|
+
* The body is async only to satisfy the lock chain (registration is
|
|
720
|
+
* synchronous; preempt no longer waits). The turn loop itself runs OUTSIDE
|
|
721
|
+
* this lock — only registration + preempt are serialized.
|
|
360
722
|
*/
|
|
361
723
|
function withPreemptLock(server, zcodeSid, body) {
|
|
362
724
|
const prev = server.preemptLocks.get(zcodeSid) ?? Promise.resolve();
|
|
@@ -379,75 +741,186 @@ function withPreemptLock(server, zcodeSid, body) {
|
|
|
379
741
|
return next;
|
|
380
742
|
}
|
|
381
743
|
/**
|
|
382
|
-
* Cancel any other in-flight turn for this zcodeSid
|
|
383
|
-
*
|
|
744
|
+
* Cancel any other in-flight turn for this zcodeSid: fire `session/stop` and
|
|
745
|
+
* signal the old turn to stop retrying, then return immediately.
|
|
384
746
|
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
747
|
+
* We do NOT wait for the old turn's runEventTurn to exit. Previously this spun
|
|
748
|
+
* on `pendingTurns` deletion (the old turn's finally), but that signal only
|
|
749
|
+
* proves "the old turn's loop returned" — NOT "the backend is ready for a new
|
|
750
|
+
* turn". Waiting on it blocked the new prompt in a long loading state while
|
|
751
|
+
* the backend's stop-recovery window elapsed, and it still didn't prevent the
|
|
752
|
+
* next send from racing the backend. The backend's prompt lock is the only
|
|
753
|
+
* authoritative readiness signal: the new prompt's `session/send` retries
|
|
754
|
+
* until the lock releases, so there is nothing useful to wait for here.
|
|
388
755
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
756
|
+
* The old turn's runEventTurn ends on its own once it sees a terminal event
|
|
757
|
+
* from the backend (turn.completed/turn.failed after stop). Until then it
|
|
758
|
+
* keeps dispatching whatever the backend sends for this session — which is
|
|
759
|
+
* correct, because within a single session the backend is the single source
|
|
760
|
+
* of truth and its events should reach the client.
|
|
393
761
|
*
|
|
394
|
-
*
|
|
395
|
-
* will then hit the lock and take the existing error path.
|
|
762
|
+
* Exported for unit tests (multi-turn pendingTurns scenarios).
|
|
396
763
|
*/
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
|
|
764
|
+
export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
|
|
765
|
+
// Cancel ALL matching turns (mirrors cancel()): pendingTurns can hold more
|
|
766
|
+
// than one entry for this session — e.g. an already-cancelled turn still
|
|
767
|
+
// finalising plus the live one. Breaking on the first match could hit the
|
|
768
|
+
// stale entry and leave the live turn running, so the new prompt's send
|
|
769
|
+
// would retry against a busy backend for 30s and fail. The stopSent guard
|
|
770
|
+
// dedupes the backend stop call across turns.
|
|
771
|
+
let found = false;
|
|
400
772
|
for (const [reqId, turn] of server.pendingTurns) {
|
|
401
|
-
if (turn.zcodeSid
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (oldRequestId === undefined)
|
|
408
|
-
return; // no in-flight turn, proceed
|
|
409
|
-
log(` [preempt] in-flight turn ${oldRequestId} found, stopping it`);
|
|
410
|
-
// Fire-and-forget stop (mirrors Python's _cancel_backend_turn). The old
|
|
411
|
-
// turn loop will receive turn.completed(cancelled) and exit on its own.
|
|
412
|
-
stopBackendTurn(server, zcodeSid);
|
|
413
|
-
// Wait for the old turn's prompt() to fully exit (its finally block deletes
|
|
414
|
-
// the pendingTurns entry). This is the synchronization point that guarantees
|
|
415
|
-
// both lock release (backend turn ended) and listener unregistration before
|
|
416
|
-
// we subscribe/send. More reliable than probing session/goal show.
|
|
417
|
-
const PREEMPT_TIMEOUT_MS = 35_000;
|
|
418
|
-
const t0 = Date.now();
|
|
419
|
-
while (server.pendingTurns.has(oldRequestId)) {
|
|
420
|
-
if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) {
|
|
421
|
-
warn(` [preempt] timed out waiting for old turn ${oldRequestId} to exit`);
|
|
422
|
-
return; // best-effort: continue anyway, session/send may fail
|
|
773
|
+
if (turn.zcodeSid !== zcodeSid || reqId === selfRequestId)
|
|
774
|
+
continue;
|
|
775
|
+
turn.cancelled = true; // signal the old turn to stop its retry loops
|
|
776
|
+
if (!turn.stopSent) {
|
|
777
|
+
stopBackendTurn(server, zcodeSid);
|
|
778
|
+
turn.stopSent = true;
|
|
423
779
|
}
|
|
424
|
-
|
|
780
|
+
// Record cancel time so the prompt()'s send-retry can use the recovery
|
|
781
|
+
// window as a hint (see session/send retry loop).
|
|
782
|
+
server.lastCancelledAt.set(zcodeSid, Date.now());
|
|
783
|
+
log(` [preempt] in-flight turn ${reqId} cancelled, proceeding without waiting`);
|
|
784
|
+
found = true;
|
|
425
785
|
}
|
|
426
|
-
|
|
786
|
+
return found;
|
|
427
787
|
}
|
|
428
788
|
// ---------- internals ----------
|
|
429
|
-
/** Concatenate text from ACP
|
|
430
|
-
|
|
789
|
+
/** Concatenate text from ACP ContentBlock[] into a prompt string.
|
|
790
|
+
* Exported for unit testing (the resource_link path is easy to break). */
|
|
791
|
+
export function extractPromptText(blocks) {
|
|
431
792
|
const parts = [];
|
|
432
793
|
for (const block of blocks ?? []) {
|
|
794
|
+
// ACP ContentBlock is a discriminated union on `type`. The resource_link
|
|
795
|
+
// variant carries `name` + `uri` flat on the block itself (NOT nested under
|
|
796
|
+
// a `resource_link` key — see ACP schema $defs.ResourceLink). Accessing
|
|
797
|
+
// `block.resource_link` silently dropped every dragged-file attachment.
|
|
433
798
|
const b = block;
|
|
434
799
|
if (b.type === "text" && b.text) {
|
|
435
800
|
parts.push(b.text);
|
|
436
801
|
}
|
|
437
|
-
else if (b.type === "resource_link" && b.
|
|
438
|
-
|
|
802
|
+
else if (b.type === "resource_link" && b.uri) {
|
|
803
|
+
// Convert file:// URIs to absolute paths so the model treats them as
|
|
804
|
+
// readable filesystem locations rather than opaque hyperlinks. Fall
|
|
805
|
+
// back to the path when name is missing OR empty — the ACP schema
|
|
806
|
+
// requires `name`, but a non-compliant client still deserves useful
|
|
807
|
+
// prompt text rather than `[related resource: ](/path)`.
|
|
808
|
+
const path = b.uri.startsWith("file://") ? fileUriToPath(b.uri) : b.uri;
|
|
809
|
+
const label = b.name || path;
|
|
810
|
+
parts.push(`[related resource: ${label}](${path})`);
|
|
811
|
+
}
|
|
812
|
+
else if (b.type === "resource" && b.resource) {
|
|
813
|
+
// Embedded resource. We don't advertise embeddedContext, but accept text
|
|
814
|
+
// payloads defensively in case a client sends them anyway. Binary
|
|
815
|
+
// payloads (BlobResourceContents) are never decoded — the base64 blob is
|
|
816
|
+
// useless to the model — so rewrite the resource uri into a readable
|
|
817
|
+
// filesystem location (same treatment as resource_link). Dropping it
|
|
818
|
+
// entirely left the prompt empty, which errored on a binary-only drag.
|
|
819
|
+
const r = b.resource;
|
|
820
|
+
if (r.text) {
|
|
821
|
+
parts.push(r.text);
|
|
822
|
+
}
|
|
823
|
+
else if (r.blob && r.uri) {
|
|
824
|
+
const path = r.uri.startsWith("file://") ? fileUriToPath(r.uri) : r.uri;
|
|
825
|
+
const label = basename(path) || path;
|
|
826
|
+
parts.push(`[related resource: ${label}](${path})`);
|
|
827
|
+
}
|
|
439
828
|
}
|
|
440
829
|
}
|
|
441
830
|
return parts.join("\n").trim();
|
|
442
831
|
}
|
|
443
|
-
/**
|
|
444
|
-
|
|
832
|
+
/** Extension inferred from mimeType for synthesizing a filename. */
|
|
833
|
+
const MIME_EXT = {
|
|
834
|
+
"image/png": "png",
|
|
835
|
+
"image/jpeg": "jpg",
|
|
836
|
+
"image/gif": "gif",
|
|
837
|
+
"image/webp": "webp",
|
|
838
|
+
"image/bmp": "bmp",
|
|
839
|
+
"image/svg+xml": "svg",
|
|
840
|
+
};
|
|
841
|
+
/**
|
|
842
|
+
* Extract image attachments from ACP ContentBlock[]. Non-image blocks are
|
|
843
|
+
* ignored (text/resource_link/resource stay owned by `extractPromptText`).
|
|
844
|
+
* Exported for unit testing.
|
|
845
|
+
*/
|
|
846
|
+
export function extractAttachments(blocks) {
|
|
847
|
+
const out = [];
|
|
848
|
+
let imageIndex = 0;
|
|
849
|
+
for (const block of blocks ?? []) {
|
|
850
|
+
const b = block;
|
|
851
|
+
if (b.type !== "image")
|
|
852
|
+
continue;
|
|
853
|
+
imageIndex += 1;
|
|
854
|
+
const mimeType = b.mimeType ?? "image/png";
|
|
855
|
+
// Prefer a file:// uri → localPath so the backend streams from disk.
|
|
856
|
+
const uri = typeof b.uri === "string" ? b.uri : "";
|
|
857
|
+
if (uri.startsWith("file://")) {
|
|
858
|
+
const localPath = fileUriToPath(uri);
|
|
859
|
+
out.push({
|
|
860
|
+
kind: "image",
|
|
861
|
+
filename: basename(localPath) ?? `image-${imageIndex}.${MIME_EXT[mimeType] ?? "png"}`,
|
|
862
|
+
mimeType,
|
|
863
|
+
localPath,
|
|
864
|
+
});
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
// Otherwise fall back to the base64 payload.
|
|
868
|
+
if (b.data) {
|
|
869
|
+
out.push({
|
|
870
|
+
kind: "image",
|
|
871
|
+
filename: uri
|
|
872
|
+
? (basename(uri) ?? `image-${imageIndex}.${MIME_EXT[mimeType] ?? "png"}`)
|
|
873
|
+
: `image-${imageIndex}.${MIME_EXT[mimeType] ?? "png"}`,
|
|
874
|
+
mimeType,
|
|
875
|
+
dataBase64: b.data,
|
|
876
|
+
sizeBytes: Math.floor((b.data.length * 3) / 4),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// An image block with neither a usable uri nor data is dropped defensively.
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
/** Best-effort basename from a path/uri (no node:path import for a tiny helper). */
|
|
884
|
+
function basename(p) {
|
|
885
|
+
const clean = p.replace(/\/+$/, "");
|
|
886
|
+
const slash = clean.lastIndexOf("/");
|
|
887
|
+
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
|
|
888
|
+
return name || null;
|
|
889
|
+
}
|
|
890
|
+
/** Convert a file:// URI to an absolute filesystem path. */
|
|
891
|
+
function fileUriToPath(uri) {
|
|
892
|
+
try {
|
|
893
|
+
return decodeURIComponent(new URL(uri).pathname);
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
// Not a valid URL — return as-is (best-effort).
|
|
897
|
+
return uri;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Resume a zcode session with retry on transient timeouts.
|
|
902
|
+
*
|
|
903
|
+
* The backend drops RPCs issued during its cold-start window (between process
|
|
904
|
+
* spawn and `startup.completed`). The first resume after a fresh backend spawn
|
|
905
|
+
* can land in that gap and time out without the backend ever seeing it. A single
|
|
906
|
+
* retry — issued after the startup window has elapsed — succeeds. Non-timeout
|
|
907
|
+
* errors (Invalid params, session not found) fail fast.
|
|
908
|
+
*/
|
|
909
|
+
async function resumeBackendSession(server, zcParams) {
|
|
445
910
|
const backend = server.ensureBackend();
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
911
|
+
const MAX_ATTEMPTS = 2;
|
|
912
|
+
const ATTEMPT_TIMEOUT_MS = 15_000;
|
|
913
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
914
|
+
const resp = await backend.request(server.nextId(), "session/resume", zcParams, ATTEMPT_TIMEOUT_MS);
|
|
915
|
+
if (!resp.error)
|
|
916
|
+
return;
|
|
917
|
+
const isTimeout = resp.error.message === "timeout";
|
|
918
|
+
if (!isTimeout || attempt === MAX_ATTEMPTS) {
|
|
919
|
+
throw new Error(`zcode resume failed: ${resp.error.message ?? ""}`);
|
|
920
|
+
}
|
|
921
|
+
log(`session/resume attempt ${attempt}/${MAX_ATTEMPTS} timed out, retrying (backend cold-start window)`);
|
|
922
|
+
await sleep(1000);
|
|
923
|
+
}
|
|
451
924
|
}
|
|
452
925
|
/** Get or create the session-level ProjectionDiffer (persists across turns). */
|
|
453
926
|
function getOrCreateDiffer(server, zcodeSid) {
|
|
@@ -469,7 +942,7 @@ function getOrCreateDiffer(server, zcodeSid) {
|
|
|
469
942
|
* handling (requestPermission / ExitPlanMode / AskUserQuestion) lands in
|
|
470
943
|
* Commit 6 — for now they're polled to keep the inbox clear.
|
|
471
944
|
*/
|
|
472
|
-
async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn) {
|
|
945
|
+
async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, preempted) {
|
|
473
946
|
const backend = server.ensureBackend();
|
|
474
947
|
const translator = new EventTranslator();
|
|
475
948
|
differ.resetTurn();
|
|
@@ -478,6 +951,16 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
478
951
|
let lastStallCheck = Date.now();
|
|
479
952
|
let emittedText = false;
|
|
480
953
|
let emittedOutput = false;
|
|
954
|
+
// Thinking-phase feedback: GLM models spend seconds in CoT before emitting
|
|
955
|
+
// any model.streaming event, during which the backend is silent and the
|
|
956
|
+
// editor shows nothing — users perceive this as "frozen". To bridge that
|
|
957
|
+
// gap we emit ONE agent_thought_chunk hint shortly after the turn starts,
|
|
958
|
+
// but only if no real output (text / reasoning / tool) has arrived yet.
|
|
959
|
+
// It uses a dedicated messageId so it never collides with the real reasoning
|
|
960
|
+
// stream (thought_<chunkMsgId>) and is naturally superseded once content flows.
|
|
961
|
+
let turnStartedAt = null;
|
|
962
|
+
let thinkingHintSent = false;
|
|
963
|
+
const THINKING_HINT_DELAY_MS = 1200;
|
|
481
964
|
while (Date.now() - lastProgress < NO_PROGRESS_MS) {
|
|
482
965
|
// Drain + handle server→client requests (interaction/*). Refreshes the
|
|
483
966
|
// no-progress timer when any are handled. Pass `turn` so interaction
|
|
@@ -486,33 +969,97 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
486
969
|
lastProgress = Date.now();
|
|
487
970
|
}
|
|
488
971
|
if (turn.cancelled) {
|
|
489
|
-
|
|
490
|
-
|
|
972
|
+
// Cancel requested: ensure stop was fired (cancel()/preempt normally do
|
|
973
|
+
// this, but guard anyway). We do NOT silence subsequent events here — if
|
|
974
|
+
// the backend ignored the stop and kept producing, that content is still
|
|
975
|
+
// valuable to the user and should be displayed (the backend is the single
|
|
976
|
+
// source of truth within a session). Cross-turn contamination is handled
|
|
977
|
+
// separately by the turn-attribution gate below, which discards this
|
|
978
|
+
// turn's leftover events from the *next* turn's queue. The loop exits
|
|
979
|
+
// normally on the terminal event (translator.turnDone below).
|
|
980
|
+
if (!turn.stopSent) {
|
|
981
|
+
stopBackendTurn(server, turn.zcodeSid);
|
|
982
|
+
turn.stopSent = true;
|
|
983
|
+
}
|
|
491
984
|
}
|
|
492
985
|
const ev = await listener.pollEvent(500);
|
|
493
986
|
if (ev === null) {
|
|
987
|
+
// Thinking-phase hint: if the turn has started but produced no output
|
|
988
|
+
// yet (no text/reasoning/tool streamed), and we've been silent longer
|
|
989
|
+
// than the threshold, emit a single "thinking" thought chunk so the
|
|
990
|
+
// editor shows activity instead of a frozen screen. Skipped once any
|
|
991
|
+
// real output has been dispatched, and never sent after cancellation.
|
|
992
|
+
if (!turn.cancelled &&
|
|
993
|
+
!thinkingHintSent &&
|
|
994
|
+
turnStartedAt !== null &&
|
|
995
|
+
!emittedText &&
|
|
996
|
+
!emittedOutput &&
|
|
997
|
+
Date.now() - turnStartedAt > THINKING_HINT_DELAY_MS) {
|
|
998
|
+
thinkingHintSent = true;
|
|
999
|
+
await sendSessionUpdate(cx, acpSid, {
|
|
1000
|
+
sessionUpdate: "agent_thought_chunk",
|
|
1001
|
+
content: { type: "text", text: "正在思考…" },
|
|
1002
|
+
messageId: `thinking_${chunkMsgId}`,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
494
1005
|
// Stall reconciliation: probe authoritative status after 15s of silence.
|
|
495
|
-
|
|
1006
|
+
// Skipped while cancelled: we've already fired stop, so the backend will
|
|
1007
|
+
// emit its own completion event, and this branch would otherwise push
|
|
1008
|
+
// stale output or return a wrong stopReason (end_turn / throw) after the
|
|
1009
|
+
// user stopped.
|
|
1010
|
+
if (!turn.cancelled &&
|
|
1011
|
+
translator.turnStarted &&
|
|
496
1012
|
Date.now() - lastProgress > 15_000 &&
|
|
497
1013
|
Date.now() - lastStallCheck > 15_000) {
|
|
498
1014
|
lastStallCheck = Date.now();
|
|
499
1015
|
const proj = await monitor.pollOnce();
|
|
500
1016
|
if (proj?.status === "idle") {
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1017
|
+
// A single idle probe can also fire mid-work: the backend is silent
|
|
1018
|
+
// during the model's thinking/connection phase and may report idle
|
|
1019
|
+
// while the turn is still alive. Confirm before trusting it — wait
|
|
1020
|
+
// briefly, then probe once more. Only a second idle WITH no queued
|
|
1021
|
+
// events ends the turn: an event arriving in the window proves the
|
|
1022
|
+
// turn is alive (it stays queued for the next poll).
|
|
1023
|
+
await sleep(1500);
|
|
1024
|
+
if (listener.hasQueuedEvents()) {
|
|
1025
|
+
lastProgress = Date.now();
|
|
1026
|
+
continue; // alive — events will be consumed by the next poll
|
|
1027
|
+
}
|
|
1028
|
+
const proj2 = await monitor.pollOnce();
|
|
1029
|
+
if (proj2?.status === "idle" && !listener.hasQueuedEvents()) {
|
|
1030
|
+
// Turn completed but the event was lost (double-confirmed).
|
|
1031
|
+
if (!emittedText) {
|
|
1032
|
+
const reply = await fetchLastReply(server, turn.zcodeSid, differ);
|
|
1033
|
+
if (reply) {
|
|
1034
|
+
registerFetchedReply(translator, reply);
|
|
1035
|
+
await sendTextChunk(cx, acpSid, reply.text, chunkMsgId);
|
|
1036
|
+
}
|
|
1037
|
+
else if (!emittedOutput) {
|
|
1038
|
+
// No text and no output → suspected failure.
|
|
1039
|
+
stopBackendTurn(server, turn.zcodeSid);
|
|
1040
|
+
throw new RequestError(-32603, "turn produced no output");
|
|
1041
|
+
}
|
|
511
1042
|
}
|
|
1043
|
+
// Heuristic ending: prompt() must skip auto-compact for this
|
|
1044
|
+
// turn — the completion was inferred, and compressing an
|
|
1045
|
+
// in-flight task's context would destroy the work.
|
|
1046
|
+
turn.stallRecovered = true;
|
|
1047
|
+
return { stopReason: "end_turn" };
|
|
1048
|
+
}
|
|
1049
|
+
// Second probe says the backend is still working (or events arrived
|
|
1050
|
+
// mid-probe) — keep waiting; queued events are consumed by the next
|
|
1051
|
+
// poll iteration.
|
|
1052
|
+
lastProgress = Date.now();
|
|
1053
|
+
if (proj2?.status === "running") {
|
|
1054
|
+
await listener.resubscribe(() => server.nextId());
|
|
512
1055
|
}
|
|
513
|
-
|
|
1056
|
+
continue;
|
|
514
1057
|
}
|
|
515
1058
|
if (proj?.status === "running") {
|
|
1059
|
+
// Backend still working (or recovering from a stop) — keep waiting.
|
|
1060
|
+
// The send-retry loop in prompt() already covers the recovery window
|
|
1061
|
+
// for the NEXT turn; for this in-flight turn we just resubscribe and
|
|
1062
|
+
// let the backend emit its terminal event when ready.
|
|
516
1063
|
lastProgress = Date.now();
|
|
517
1064
|
await listener.resubscribe(() => server.nextId());
|
|
518
1065
|
}
|
|
@@ -520,9 +1067,35 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
520
1067
|
continue;
|
|
521
1068
|
}
|
|
522
1069
|
lastProgress = Date.now();
|
|
1070
|
+
// Turn-attribution gate: before this turn's own turn.started arrives, any
|
|
1071
|
+
// event is leftover from a prior turn (cancelled/preempted but still
|
|
1072
|
+
// finalising) that landed in the queue while send was retrying on a busy
|
|
1073
|
+
// backend. Discard it — including a prior turn's turn.completed, which
|
|
1074
|
+
// would otherwise make this turn exit (cancelled) before it even begins.
|
|
1075
|
+
//
|
|
1076
|
+
// The gate must run BEFORE translate(): translator flags (turnDone /
|
|
1077
|
+
// turnFailed / turnResultType) are sticky, so translating a prior turn's
|
|
1078
|
+
// terminal event here would flip them and make THIS turn exit prematurely
|
|
1079
|
+
// at the first check after its own turn.started passes the gate.
|
|
1080
|
+
//
|
|
1081
|
+
// The gate is armed ONLY when this send preempted another prompt. Without
|
|
1082
|
+
// preemption no prior-turn residue can exist: the queue can only contain
|
|
1083
|
+
// events of a backend-owned turn that was already active at send time
|
|
1084
|
+
// (e.g. the main-branch turn auto-resumed after a compaction) — this send
|
|
1085
|
+
// was steered into it and produces NO new turn.started, so dropping those
|
|
1086
|
+
// events would silently swallow the entire turn's output in the UI.
|
|
1087
|
+
if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, preempted)) {
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
523
1090
|
const internalEvents = translator.translate(ev);
|
|
1091
|
+
// Capture the turn-start timestamp for the thinking-phase hint above.
|
|
1092
|
+
// Done after translate so the flag flip on the turn.started event is
|
|
1093
|
+
// observed on the same iteration that processes it.
|
|
1094
|
+
if (turnStartedAt === null && translator.turnStarted) {
|
|
1095
|
+
turnStartedAt = Date.now();
|
|
1096
|
+
}
|
|
524
1097
|
for (const iev of internalEvents) {
|
|
525
|
-
if (iev.kind === "TextDelta")
|
|
1098
|
+
if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta")
|
|
526
1099
|
emittedText = true;
|
|
527
1100
|
if (iev.kind === "ToolCallNew" || iev.kind === "ToolCallUpdate")
|
|
528
1101
|
emittedOutput = true;
|
|
@@ -533,18 +1106,34 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
533
1106
|
differ.setLastUsage(iev.used);
|
|
534
1107
|
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
|
|
535
1108
|
}
|
|
536
|
-
// Edit/Write diff eager dispatch: on tool.updated result
|
|
537
|
-
//
|
|
538
|
-
//
|
|
1109
|
+
// Edit/Write diff eager dispatch: on tool.updated result, grab the
|
|
1110
|
+
// structured patch from session/messages immediately (don't wait for turn
|
|
1111
|
+
// completion — model rate-limiting could delay it indefinitely).
|
|
1112
|
+
//
|
|
1113
|
+
// Newer ZCode backends omit toolName on "result" events (only "scheduled"
|
|
1114
|
+
// and "started" carry it), so we no longer filter by tool name here —
|
|
1115
|
+
// dispatchEditDiff itself checks the tool part's display and skips
|
|
1116
|
+
// non-file-diff tools harmlessly.
|
|
539
1117
|
if (ev.type === "tool.updated") {
|
|
540
1118
|
const payload = ev.payload;
|
|
541
|
-
if (payload.kind === "result" &&
|
|
542
|
-
|
|
543
|
-
(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
payload.
|
|
547
|
-
|
|
1119
|
+
if (payload.kind === "result" && payload.toolCallId) {
|
|
1120
|
+
// Fire edit-diff and plan-sync in parallel — they hit independent
|
|
1121
|
+
// backend methods (session/messages vs session/read) so there's no
|
|
1122
|
+
// ordering dependency between them.
|
|
1123
|
+
const sideTasks = [
|
|
1124
|
+
dispatchEditDiff(server, cx, acpSid, turn.zcodeSid, payload.toolCallId, differ, chunkMsgId),
|
|
1125
|
+
// Push plan (TODO list) updates immediately on tool completion so the
|
|
1126
|
+
// editor doesn't lag behind — without this, TODO changes only surface at
|
|
1127
|
+
// turn completion, which can be delayed by the model's remaining output.
|
|
1128
|
+
dispatchPlanIfChanged(server, cx, acpSid, turn.zcodeSid, differ, chunkMsgId),
|
|
1129
|
+
];
|
|
1130
|
+
// EnterPlanMode switches the session mode mid-turn without a
|
|
1131
|
+
// session/setMode notification; reconcile immediately so the editor's
|
|
1132
|
+
// mode indicator flips without waiting for turn completion.
|
|
1133
|
+
if (translator.toolNames.get(payload.toolCallId) === "EnterPlanMode") {
|
|
1134
|
+
sideTasks.push(emitModeIfChanged(server, cx, acpSid, turn.zcodeSid));
|
|
1135
|
+
}
|
|
1136
|
+
await Promise.all(sideTasks);
|
|
548
1137
|
}
|
|
549
1138
|
}
|
|
550
1139
|
// Sync translator → differ seen-tool-ids so the turn-completion differ.diff
|
|
@@ -555,38 +1144,53 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
555
1144
|
differ.markToolSeen(seenId);
|
|
556
1145
|
}
|
|
557
1146
|
if (translator.turnDone) {
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
|
|
1147
|
+
// User requested cancel (via cancel()/preempt). Whatever the backend's
|
|
1148
|
+
// terminal resultType (cancelled / success / failed), honour the user's
|
|
1149
|
+
// intent and report cancelled.
|
|
1150
|
+
if (turn.cancelled || translator.turnResultType === "cancelled") {
|
|
561
1151
|
return { stopReason: "cancelled" };
|
|
562
1152
|
}
|
|
563
1153
|
if (translator.turnFailed) {
|
|
564
1154
|
// Best-effort stop in case the failed turn left a residual lock.
|
|
565
1155
|
stopBackendTurn(server, turn.zcodeSid);
|
|
566
|
-
|
|
1156
|
+
// Throw a TurnFailedError carrying the structured error so the caller
|
|
1157
|
+
// (prompt's retry loop) can classify transient vs fatal. The error
|
|
1158
|
+
// message is formatted for display when it ultimately reaches the user.
|
|
1159
|
+
throw new TurnFailedError(translator.turnError ?? {});
|
|
567
1160
|
}
|
|
568
1161
|
// Fallback: if no text streamed, surface the last assistant reply.
|
|
569
1162
|
if (!emittedText) {
|
|
570
1163
|
const reply = await fetchLastReply(server, turn.zcodeSid, differ);
|
|
571
|
-
if (reply)
|
|
572
|
-
|
|
1164
|
+
if (reply) {
|
|
1165
|
+
registerFetchedReply(translator, reply);
|
|
1166
|
+
await sendTextChunk(cx, acpSid, reply.text, chunkMsgId);
|
|
1167
|
+
}
|
|
573
1168
|
}
|
|
574
1169
|
// Turn-completion diff: emits PlanUpdate (todos) + final usage_update,
|
|
575
|
-
// reconciles any snapshot-only tool events
|
|
1170
|
+
// reconciles any snapshot-only tool events, and replays assistant text
|
|
1171
|
+
// that never reached the live event stream.
|
|
1172
|
+
//
|
|
1173
|
+
// TextDelta/ReasoningDelta are filtered only when the same message was
|
|
1174
|
+
// ALREADY streamed live (dedup by backend message id — `translator`
|
|
1175
|
+
// records `assistantMessageId` per streamed delta, the differ tags its
|
|
1176
|
+
// replay with the same id). The differ's seenMessageIds dedup cannot
|
|
1177
|
+
// bridge the two paths because the streaming path uses a client-generated
|
|
1178
|
+
// chunkMsgId while the differ keys on the backend's message info.id.
|
|
576
1179
|
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// reply and reasoning are dispatched a second time. `fetchLastReply`
|
|
584
|
-
// above already covers the case where the event path delivered no text.
|
|
1180
|
+
// Without this per-message dedup the whole reply would be dispatched a
|
|
1181
|
+
// second time; without the replay, a backend turn resumed while no
|
|
1182
|
+
// listener was attached (e.g. the main-branch turn auto-resumed after
|
|
1183
|
+
// compaction, before the user's next send) would leave its entire output
|
|
1184
|
+
// invisible in the UI. `fetchLastReply` above only covers the last
|
|
1185
|
+
// assistant message, not the whole missing span.
|
|
585
1186
|
const snapshot = await buildSnapshot(server, turn.zcodeSid);
|
|
586
1187
|
const completionEvents = differ.diff(snapshot);
|
|
587
1188
|
for (const iev of completionEvents) {
|
|
588
|
-
if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta")
|
|
1189
|
+
if ((iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") &&
|
|
1190
|
+
iev.messageId &&
|
|
1191
|
+
translator.deliveredMessageIds.has(iev.messageId)) {
|
|
589
1192
|
continue;
|
|
1193
|
+
}
|
|
590
1194
|
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
|
|
591
1195
|
}
|
|
592
1196
|
// Mode reconciliation: an in-turn tool (EnterPlanMode/ExitPlanMode) can
|
|
@@ -602,6 +1206,32 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
602
1206
|
stopBackendTurn(server, turn.zcodeSid);
|
|
603
1207
|
return { stopReason: "max_turn_requests" };
|
|
604
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Turn-attribution gate decision (pure, exported for tests): whether an event
|
|
1211
|
+
* observed before this turn's own `turn.started` should be dropped as leftover
|
|
1212
|
+
* residue of a prior turn.
|
|
1213
|
+
*
|
|
1214
|
+
* Residue only exists when this send preempted/cancelled another prompt (its
|
|
1215
|
+
* finalising events land in the new listener's queue). Without preemption the
|
|
1216
|
+
* queue can only carry events of a backend-owned turn already active at send
|
|
1217
|
+
* time — e.g. the main-branch turn auto-resumed after a compaction — which
|
|
1218
|
+
* this send was steered into and which emits no new `turn.started`; dropping
|
|
1219
|
+
* those events would silently swallow the whole turn's output in the UI.
|
|
1220
|
+
*/
|
|
1221
|
+
export function shouldDropEventForTurnAttribution(ev, turnStarted, preempted) {
|
|
1222
|
+
return !turnStarted && preempted && ev.type !== "turn.started";
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Register a fetchLastReply-delivered message as text-delivered so the
|
|
1226
|
+
* turn-completion diff replay doesn't dispatch the same text a second time
|
|
1227
|
+
* (the differ never saw this message — its live events were lost — so its
|
|
1228
|
+
* diff would re-emit the TextDelta). Reasoning is NOT registered: it was
|
|
1229
|
+
* never streamed either, so the replay dispatching it is pure gain.
|
|
1230
|
+
*/
|
|
1231
|
+
function registerFetchedReply(translator, reply) {
|
|
1232
|
+
if (reply.messageId)
|
|
1233
|
+
translator.deliveredMessageIds.add(reply.messageId);
|
|
1234
|
+
}
|
|
605
1235
|
/**
|
|
606
1236
|
* Fetch the last assistant message text as a fallback for lost text events.
|
|
607
1237
|
*
|
|
@@ -627,7 +1257,7 @@ async function fetchLastReply(server, zcodeSid, differ) {
|
|
|
627
1257
|
if (p && typeof p === "object" && p.type === "text") {
|
|
628
1258
|
const text = p.text ?? "";
|
|
629
1259
|
if (text.trim())
|
|
630
|
-
return text;
|
|
1260
|
+
return { text, messageId: m.info?.id ?? null };
|
|
631
1261
|
}
|
|
632
1262
|
}
|
|
633
1263
|
}
|
|
@@ -673,7 +1303,7 @@ async function buildSnapshot(server, zcodeSid) {
|
|
|
673
1303
|
* emit no notification of their own. Best-effort: failures are logged and
|
|
674
1304
|
* swallowed so they never break the turn-completion path.
|
|
675
1305
|
*/
|
|
676
|
-
async function emitModeIfChanged(server, cx, acpSid, zcodeSid) {
|
|
1306
|
+
export async function emitModeIfChanged(server, cx, acpSid, zcodeSid) {
|
|
677
1307
|
try {
|
|
678
1308
|
const modes = await buildModes(server, zcodeSid);
|
|
679
1309
|
const last = server.lastMode.get(acpSid);
|
|
@@ -732,6 +1362,31 @@ async function dispatchEditDiff(server, cx, acpSid, zcodeSid, callId, differ, ch
|
|
|
732
1362
|
}
|
|
733
1363
|
differ.markToolSeen(callId);
|
|
734
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Read the authoritative todos from `session/read` and push a PlanUpdate if the
|
|
1367
|
+
* signature changed since the last check. Called mid-turn (right after each
|
|
1368
|
+
* tool completes) so the editor sees TODO updates immediately instead of
|
|
1369
|
+
* waiting for turn completion — the turn-completion diff would otherwise lag
|
|
1370
|
+
* behind by the rest of the model's output.
|
|
1371
|
+
*
|
|
1372
|
+
* Uses a lightweight `session/read` (no session/messages fetch). Failures are
|
|
1373
|
+
* logged and swallowed: plan staleness is cosmetic, not worth crashing the turn.
|
|
1374
|
+
*/
|
|
1375
|
+
async function dispatchPlanIfChanged(server, cx, acpSid, zcodeSid, differ, chunkMsgId) {
|
|
1376
|
+
try {
|
|
1377
|
+
const backend = server.ensureBackend();
|
|
1378
|
+
const readResp = await backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 8000);
|
|
1379
|
+
const read = (readResp.result ?? {});
|
|
1380
|
+
const todos = flattenTodos(read.todos, read.todoGroups);
|
|
1381
|
+
const events = differ.diffPlan(todos);
|
|
1382
|
+
for (const iev of events) {
|
|
1383
|
+
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
catch (e) {
|
|
1387
|
+
log(`dispatchPlanIfChanged: skipped (${e instanceof Error ? e.message : String(e)})`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
735
1390
|
function sleep(ms) {
|
|
736
1391
|
return new Promise((r) => setTimeout(r, ms));
|
|
737
1392
|
}
|