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