@wrongstack/acp 1.0.8 → 1.0.10
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/dist/agent/protocol-contract.d.ts +9 -0
- package/dist/agent/protocol-session-ops.d.ts +1 -1
- package/dist/agent/server-agent-turn.d.ts +34 -0
- package/dist/agent.js +136 -4
- package/dist/client/acp-session.d.ts +10 -0
- package/dist/client.js +139 -25
- package/dist/index.js +148 -28
- package/dist/wrongstack-acp-agent.js +9 -3
- package/package.json +5 -5
|
@@ -117,6 +117,15 @@ export interface RunTurnApi {
|
|
|
117
117
|
output: string;
|
|
118
118
|
exitCode: number | null;
|
|
119
119
|
}>;
|
|
120
|
+
/**
|
|
121
|
+
* Send a `session/update` notification OUTSIDE a prompt turn (e.g. "a
|
|
122
|
+
* background delegation finished; send any message to continue"). ACP only
|
|
123
|
+
* starts turns on `session/prompt`, so this is the agent's one way to tell
|
|
124
|
+
* an idle client something happened. Resolves `false` and sends nothing once
|
|
125
|
+
* the session was closed or deleted. Optional: embedders that build their
|
|
126
|
+
* own `RunTurnApi` may omit it.
|
|
127
|
+
*/
|
|
128
|
+
sendSessionUpdate?(update: unknown): Promise<boolean>;
|
|
120
129
|
}
|
|
121
130
|
/**
|
|
122
131
|
* The agent's per-turn work. Streams `SessionUpdate` notifications to
|
|
@@ -20,7 +20,7 @@ export declare function errorToJsonRpc(err: unknown): {
|
|
|
20
20
|
message: string;
|
|
21
21
|
data?: unknown;
|
|
22
22
|
};
|
|
23
|
-
export declare function createRunTurnApi(sessionId: string, clientCapabilities: ClientCapabilities, request: (method: string, params: unknown) => Promise<unknown>): RunTurnApi;
|
|
23
|
+
export declare function createRunTurnApi(sessionId: string, clientCapabilities: ClientCapabilities, request: (method: string, params: unknown) => Promise<unknown>, sendSessionUpdate?: (update: unknown) => Promise<boolean>): RunTurnApi;
|
|
24
24
|
export declare function buildInitializeResult(agentName: string, modes: readonly SessionMode[], configOptions: readonly SessionConfigOption[]): {
|
|
25
25
|
protocolVersion: 1;
|
|
26
26
|
agentCapabilities: {
|
|
@@ -37,6 +37,16 @@
|
|
|
37
37
|
* `agent.run({signal})` and the underlying provider call observes
|
|
38
38
|
* it. On abort, the adapter maps the resulting `AbortError` to
|
|
39
39
|
* `{stopReason: 'cancelled'}`.
|
|
40
|
+
*
|
|
41
|
+
* Background delegations: a background `delegate` result is queued for the
|
|
42
|
+
* session's leader and injected by the core agent loop at its next iteration
|
|
43
|
+
* boundary — on ACP that is the next `session/prompt`, because ACP never
|
|
44
|
+
* starts a turn on its own. So the client is not left guessing, the adapter
|
|
45
|
+
* listens for `leader.delivery_pending` on the session agent's event bus and,
|
|
46
|
+
* while no turn is running, sends one coalesced unprompted `session/update`
|
|
47
|
+
* ("Background delegation <id> finished; send any message to continue.")
|
|
48
|
+
* through `RunTurnApi.sendSessionUpdate`. Nothing is sent after the session is
|
|
49
|
+
* disposed, and a notice failure never reaches the event bus.
|
|
40
50
|
*/
|
|
41
51
|
import type { Agent, AgentInput } from '@wrongstack/core/agent';
|
|
42
52
|
import type { ContentBlock, McpServer, PlanEntry, StopReason, ToolKind, UsageCost } from '../types/acp-v1.js';
|
|
@@ -69,6 +79,19 @@ export interface ACPServerAgentTurnOptions {
|
|
|
69
79
|
maxHistoryEntries?: number | undefined;
|
|
70
80
|
/** Maximum serialized replay bytes retained per session. Default 8 MiB. */
|
|
71
81
|
maxHistoryBytes?: number | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Coalescing window for the unprompted "background delegation finished"
|
|
84
|
+
* notice. Deliveries announced within it share one notice. Default 1500 ms.
|
|
85
|
+
*/
|
|
86
|
+
deliveryNoticeDebounceMs?: number | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Live count of results still queued for the session's leader (the host
|
|
89
|
+
* wires the core leader-delivery hub here). When provided, a notice is sent
|
|
90
|
+
* only while something is actually still pending — so a result the agent
|
|
91
|
+
* loop already drained mid-turn is never announced. When omitted, results
|
|
92
|
+
* announced during a running turn are assumed drained by that turn.
|
|
93
|
+
*/
|
|
94
|
+
pendingDeliveries?: ((sessionId: string) => number) | undefined;
|
|
72
95
|
}
|
|
73
96
|
/** A recorded conversation turn, replayable on `session/load`. */
|
|
74
97
|
interface SessionReplayUpdate {
|
|
@@ -109,6 +132,14 @@ export interface ACPServerAgentTurn {
|
|
|
109
132
|
*/
|
|
110
133
|
export declare function makeACPServerAgentTurn(opts: ACPServerAgentTurnOptions): ACPServerAgentTurn;
|
|
111
134
|
declare function finitePositiveLimit(value: number | undefined, fallback: number): number;
|
|
135
|
+
declare function finiteNonNegativeLimit(value: number | undefined, fallback: number): number;
|
|
136
|
+
/**
|
|
137
|
+
* True when an event's session id belongs to this ACP session: the ACP id
|
|
138
|
+
* itself, or the id the agent's context is bound to.
|
|
139
|
+
*/
|
|
140
|
+
declare function agentOwnsSession(agent: Agent, acpSessionId: string, eventSessionId: unknown): boolean;
|
|
141
|
+
/** The client-facing notice for one or more finished background delegations. */
|
|
142
|
+
declare function deliveryNoticeText(deliveryIds: readonly string[]): string;
|
|
112
143
|
declare function trimHistory(entries: SessionReplayUpdate[], retainedBytes: number, maxEntries: number, maxBytes: number): number;
|
|
113
144
|
declare function replayEntryBytes(entry: SessionReplayUpdate): number;
|
|
114
145
|
/**
|
|
@@ -184,6 +215,9 @@ declare function extractUsage(result: unknown): {
|
|
|
184
215
|
/** Internal deterministic seams used by the per-file coverage suite. */
|
|
185
216
|
export declare const serverAgentTurnCoverage: {
|
|
186
217
|
finitePositiveLimit: typeof finitePositiveLimit;
|
|
218
|
+
finiteNonNegativeLimit: typeof finiteNonNegativeLimit;
|
|
219
|
+
agentOwnsSession: typeof agentOwnsSession;
|
|
220
|
+
deliveryNoticeText: typeof deliveryNoticeText;
|
|
187
221
|
trimHistory: typeof trimHistory;
|
|
188
222
|
replayEntryBytes: typeof replayEntryBytes;
|
|
189
223
|
seedAgentContext: typeof seedAgentContext;
|
package/dist/agent.js
CHANGED
|
@@ -129,7 +129,7 @@ function errorToJsonRpc(err) {
|
|
|
129
129
|
const message = err instanceof Error ? err.message : String(err);
|
|
130
130
|
return { code: -32603, message };
|
|
131
131
|
}
|
|
132
|
-
function createRunTurnApi(sessionId, clientCapabilities, request) {
|
|
132
|
+
function createRunTurnApi(sessionId, clientCapabilities, request, sendSessionUpdate) {
|
|
133
133
|
return {
|
|
134
134
|
clientCapabilities,
|
|
135
135
|
requestPermission: async (req) => {
|
|
@@ -173,7 +173,8 @@ function createRunTurnApi(sessionId, clientCapabilities, request) {
|
|
|
173
173
|
} catch {
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
|
-
}
|
|
176
|
+
},
|
|
177
|
+
...sendSessionUpdate ? { sendSessionUpdate } : {}
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
180
|
function buildInitializeResult(agentName, modes, configOptions) {
|
|
@@ -435,7 +436,12 @@ async function handleSessionPromptOp(ctx, id, params) {
|
|
|
435
436
|
const api = createRunTurnApi(
|
|
436
437
|
sessionId,
|
|
437
438
|
ctx.clientCapabilities ?? {},
|
|
438
|
-
(method, req) => ctx.request(method, req)
|
|
439
|
+
(method, req) => ctx.request(method, req),
|
|
440
|
+
async (update) => {
|
|
441
|
+
if (ctx.sessions.get(sessionId) !== session) return false;
|
|
442
|
+
await ctx.sendNotification({ sessionId, update });
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
439
445
|
);
|
|
440
446
|
let result;
|
|
441
447
|
const pendingNotifications = [];
|
|
@@ -880,6 +886,76 @@ function makeACPServerAgentTurn(opts) {
|
|
|
880
886
|
const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
|
|
881
887
|
const maxHistoryEntries = finitePositiveLimit(opts.maxHistoryEntries, 1e3);
|
|
882
888
|
const maxHistoryBytes = finitePositiveLimit(opts.maxHistoryBytes, 8 * 1024 * 1024);
|
|
889
|
+
const deliveryNoticeDebounceMs = finiteNonNegativeLimit(opts.deliveryNoticeDebounceMs, 1500);
|
|
890
|
+
const notifiers = /* @__PURE__ */ new Map();
|
|
891
|
+
const stillPending = (sessionId) => {
|
|
892
|
+
if (!opts.pendingDeliveries) return true;
|
|
893
|
+
try {
|
|
894
|
+
return opts.pendingDeliveries(sessionId) > 0;
|
|
895
|
+
} catch {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
const flushDeliveryNotice = async (sessionId, state) => {
|
|
900
|
+
if (notifiers.get(sessionId) !== state || state.running || state.pending.size === 0) return;
|
|
901
|
+
const api = state.api;
|
|
902
|
+
if (!api?.sendSessionUpdate) return;
|
|
903
|
+
const ids = [...state.pending];
|
|
904
|
+
state.pending.clear();
|
|
905
|
+
if (!stillPending(sessionId)) return;
|
|
906
|
+
try {
|
|
907
|
+
await api.sendSessionUpdate({
|
|
908
|
+
sessionUpdate: "agent_message_chunk",
|
|
909
|
+
content: { type: "text", text: deliveryNoticeText(ids) }
|
|
910
|
+
});
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
const scheduleDeliveryNotice = (sessionId) => {
|
|
915
|
+
const state = notifiers.get(sessionId);
|
|
916
|
+
if (!state || state.running || state.pending.size === 0 || state.timer) return;
|
|
917
|
+
state.timer = setTimeout(() => {
|
|
918
|
+
state.timer = void 0;
|
|
919
|
+
void flushDeliveryNotice(sessionId, state);
|
|
920
|
+
}, deliveryNoticeDebounceMs);
|
|
921
|
+
state.timer.unref?.();
|
|
922
|
+
};
|
|
923
|
+
const attachDeliveryNotifier = (sessionId, agent) => {
|
|
924
|
+
const existing = notifiers.get(sessionId);
|
|
925
|
+
if (existing) return existing;
|
|
926
|
+
const bus = agent.events;
|
|
927
|
+
if (!bus?.on) return void 0;
|
|
928
|
+
const state = {
|
|
929
|
+
api: void 0,
|
|
930
|
+
pending: /* @__PURE__ */ new Set(),
|
|
931
|
+
running: false,
|
|
932
|
+
timer: void 0,
|
|
933
|
+
unsubscribe: []
|
|
934
|
+
};
|
|
935
|
+
notifiers.set(sessionId, state);
|
|
936
|
+
const owns = (eventSessionId) => agentOwnsSession(agent, sessionId, eventSessionId);
|
|
937
|
+
state.unsubscribe.push(
|
|
938
|
+
bus.on("leader.delivery_pending", (e) => {
|
|
939
|
+
try {
|
|
940
|
+
if (notifiers.get(sessionId) !== state || !owns(e?.sessionId)) return;
|
|
941
|
+
for (const id of Array.isArray(e.deliveryIds) ? e.deliveryIds : []) {
|
|
942
|
+
if (typeof id === "string" && id.length > 0) state.pending.add(id);
|
|
943
|
+
}
|
|
944
|
+
scheduleDeliveryNotice(sessionId);
|
|
945
|
+
} catch {
|
|
946
|
+
}
|
|
947
|
+
}),
|
|
948
|
+
bus.on("delegation.delivered", (e) => {
|
|
949
|
+
try {
|
|
950
|
+
if (!owns(e?.sessionId) || typeof e.delegationId !== "string") return;
|
|
951
|
+
state.pending.delete(e.delegationId);
|
|
952
|
+
state.pending.delete(`${DELEGATION_DELIVERY_PREFIX}${e.delegationId}`);
|
|
953
|
+
} catch {
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
);
|
|
957
|
+
return state;
|
|
958
|
+
};
|
|
883
959
|
const turn = async (input, emit, api) => {
|
|
884
960
|
let agent = agents.get(input.sessionId);
|
|
885
961
|
if (!agent) {
|
|
@@ -895,6 +971,16 @@ function makeACPServerAgentTurn(opts) {
|
|
|
895
971
|
seedAgentContext(agent, history.get(input.sessionId));
|
|
896
972
|
}
|
|
897
973
|
}
|
|
974
|
+
const notifier = attachDeliveryNotifier(input.sessionId, agent);
|
|
975
|
+
if (notifier) {
|
|
976
|
+
if (api) notifier.api = api;
|
|
977
|
+
notifier.running = true;
|
|
978
|
+
notifier.pending.clear();
|
|
979
|
+
if (notifier.timer) {
|
|
980
|
+
clearTimeout(notifier.timer);
|
|
981
|
+
notifier.timer = void 0;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
898
984
|
const turnAbort = new AbortController();
|
|
899
985
|
const abortForTimeout = () => turnAbort.abort();
|
|
900
986
|
const onParentAbort = () => turnAbort.abort();
|
|
@@ -1007,6 +1093,11 @@ function makeACPServerAgentTurn(opts) {
|
|
|
1007
1093
|
timeouts.delete(input.sessionId);
|
|
1008
1094
|
input.signal.removeEventListener("abort", onParentAbort);
|
|
1009
1095
|
for (const u of unsub) u();
|
|
1096
|
+
if (notifier && notifiers.get(input.sessionId) === notifier) {
|
|
1097
|
+
notifier.running = false;
|
|
1098
|
+
if (!opts.pendingDeliveries) notifier.pending.clear();
|
|
1099
|
+
scheduleDeliveryNotice(input.sessionId);
|
|
1100
|
+
}
|
|
1010
1101
|
}
|
|
1011
1102
|
};
|
|
1012
1103
|
const replay = (sessionId) => [...history.get(sessionId) ?? []];
|
|
@@ -1027,6 +1118,19 @@ function makeACPServerAgentTurn(opts) {
|
|
|
1027
1118
|
const timer = timeouts.get(sessionId);
|
|
1028
1119
|
if (timer) clearTimeout(timer);
|
|
1029
1120
|
timeouts.delete(sessionId);
|
|
1121
|
+
const notifier = notifiers.get(sessionId);
|
|
1122
|
+
if (notifier) {
|
|
1123
|
+
notifiers.delete(sessionId);
|
|
1124
|
+
if (notifier.timer) clearTimeout(notifier.timer);
|
|
1125
|
+
notifier.timer = void 0;
|
|
1126
|
+
notifier.pending.clear();
|
|
1127
|
+
for (const off of notifier.unsubscribe) {
|
|
1128
|
+
try {
|
|
1129
|
+
off();
|
|
1130
|
+
} catch {
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1030
1134
|
agents.delete(sessionId);
|
|
1031
1135
|
history.delete(sessionId);
|
|
1032
1136
|
historyBytes.delete(sessionId);
|
|
@@ -1037,6 +1141,34 @@ function makeACPServerAgentTurn(opts) {
|
|
|
1037
1141
|
function finitePositiveLimit(value, fallback) {
|
|
1038
1142
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
1039
1143
|
}
|
|
1144
|
+
function finiteNonNegativeLimit(value, fallback) {
|
|
1145
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
1146
|
+
}
|
|
1147
|
+
var DELEGATION_DELIVERY_PREFIX = "delegation:";
|
|
1148
|
+
var NOTICE_MAX_IDS = 5;
|
|
1149
|
+
function normalizeSessionKey(id) {
|
|
1150
|
+
return id.trim().replace(/\\/g, "/");
|
|
1151
|
+
}
|
|
1152
|
+
function agentOwnsSession(agent, acpSessionId, eventSessionId) {
|
|
1153
|
+
if (typeof eventSessionId !== "string" || eventSessionId.trim().length === 0) return false;
|
|
1154
|
+
const target = normalizeSessionKey(eventSessionId);
|
|
1155
|
+
const ctx = agent.ctx;
|
|
1156
|
+
const candidates = [acpSessionId, ctx?.session?.id, ctx?.meta?.["sessionId"]];
|
|
1157
|
+
return candidates.some(
|
|
1158
|
+
(c) => typeof c === "string" && c.length > 0 && normalizeSessionKey(c) === target
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
function deliveryNoticeText(deliveryIds) {
|
|
1162
|
+
const ids = deliveryIds.map(
|
|
1163
|
+
(id) => id.startsWith(DELEGATION_DELIVERY_PREFIX) ? id.slice(DELEGATION_DELIVERY_PREFIX.length) : id
|
|
1164
|
+
);
|
|
1165
|
+
if (ids.length === 1) {
|
|
1166
|
+
return `Background delegation ${ids[0]} finished; send any message to continue.`;
|
|
1167
|
+
}
|
|
1168
|
+
const shown = ids.slice(0, NOTICE_MAX_IDS).join(", ");
|
|
1169
|
+
const more = ids.length > NOTICE_MAX_IDS ? ` (+${ids.length - NOTICE_MAX_IDS} more)` : "";
|
|
1170
|
+
return `Background delegations ${shown}${more} finished; send any message to continue.`;
|
|
1171
|
+
}
|
|
1040
1172
|
function trimHistory(entries, retainedBytes, maxEntries, maxBytes) {
|
|
1041
1173
|
while (entries.length > maxEntries || retainedBytes > maxBytes) {
|
|
1042
1174
|
const removed = entries.shift();
|
|
@@ -1172,7 +1304,7 @@ function extractUsage(result) {
|
|
|
1172
1304
|
const r = result;
|
|
1173
1305
|
if (typeof r.usage === "object" && r.usage !== null) {
|
|
1174
1306
|
const u = r.usage;
|
|
1175
|
-
if (typeof u.used === "number" && typeof u.size === "number") {
|
|
1307
|
+
if (typeof u.used === "number" && Number.isFinite(u.used) && typeof u.size === "number" && Number.isFinite(u.size)) {
|
|
1176
1308
|
return {
|
|
1177
1309
|
used: u.used,
|
|
1178
1310
|
size: u.size,
|
|
@@ -103,6 +103,16 @@ export declare class ACPSession {
|
|
|
103
103
|
setProvider(providerId: string, config?: Record<string, unknown>): Promise<void>;
|
|
104
104
|
disableProvider(): Promise<void>;
|
|
105
105
|
prompt(blocks: ContentBlock[], signal: AbortSignal, onProgress?: ACPProgressHandler): Promise<ACPSessionRunResult>;
|
|
106
|
+
/**
|
|
107
|
+
* Best-effort cancel for a session the server confirmed after we already
|
|
108
|
+
* stopped waiting (the abort-during-creation race). `session/cancel` is a
|
|
109
|
+
* JSON-RPC notification — the server sends no response, so this is a bare
|
|
110
|
+
* transport send bounded by a timer rather than sendRequest, whose
|
|
111
|
+
* pending-tracking would just expire waiting for a reply that never
|
|
112
|
+
* comes. A failure or timeout is surfaced on the warn channel instead of
|
|
113
|
+
* being silently swallowed.
|
|
114
|
+
*/
|
|
115
|
+
private cancelLateSession;
|
|
106
116
|
private closeSession;
|
|
107
117
|
close(): Promise<void>;
|
|
108
118
|
private allocId;
|
package/dist/client.js
CHANGED
|
@@ -1641,6 +1641,7 @@ function finitePositiveLimit(value, fallback) {
|
|
|
1641
1641
|
}
|
|
1642
1642
|
|
|
1643
1643
|
// src/client/acp-session.ts
|
|
1644
|
+
var LATE_CANCEL_SEND_TIMEOUT_MS = 1e4;
|
|
1644
1645
|
var ACPSession = class _ACPSession {
|
|
1645
1646
|
transport;
|
|
1646
1647
|
fileServer;
|
|
@@ -1941,8 +1942,62 @@ var ACPSession = class _ACPSession {
|
|
|
1941
1942
|
if (signal.aborted) {
|
|
1942
1943
|
return emptyRunResult("cancelled");
|
|
1943
1944
|
}
|
|
1945
|
+
let cancelled = false;
|
|
1946
|
+
this.promptCallbackAbort = new AbortController();
|
|
1947
|
+
const onAbort = () => {
|
|
1948
|
+
cancelled = true;
|
|
1949
|
+
this.promptCallbackAbort?.abort();
|
|
1950
|
+
if (this.sessionId) {
|
|
1951
|
+
this.transport.send({
|
|
1952
|
+
jsonrpc: "2.0",
|
|
1953
|
+
method: "session/cancel",
|
|
1954
|
+
params: { sessionId: this.sessionId }
|
|
1955
|
+
}).catch(() => {
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1960
|
+
let rejectCreate;
|
|
1961
|
+
const onCreateAbort = () => {
|
|
1962
|
+
signal.removeEventListener("abort", onAbort);
|
|
1963
|
+
rejectCreate?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
|
|
1964
|
+
};
|
|
1944
1965
|
if (!this.sessionId) {
|
|
1945
|
-
|
|
1966
|
+
let sessionId;
|
|
1967
|
+
const createPromise = this.createSessionWithAuth();
|
|
1968
|
+
try {
|
|
1969
|
+
sessionId = await Promise.race([
|
|
1970
|
+
createPromise,
|
|
1971
|
+
new Promise((_, reject) => {
|
|
1972
|
+
rejectCreate = reject;
|
|
1973
|
+
if (signal.aborted) {
|
|
1974
|
+
reject(new ACPSessionError("aborted", "prompt was aborted by the parent"));
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
signal.addEventListener("abort", onCreateAbort, { once: true });
|
|
1978
|
+
})
|
|
1979
|
+
]);
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
signal.removeEventListener("abort", onAbort);
|
|
1982
|
+
signal.removeEventListener("abort", onCreateAbort);
|
|
1983
|
+
rejectCreate = void 0;
|
|
1984
|
+
this.promptCallbackAbort?.abort();
|
|
1985
|
+
this.promptCallbackAbort = null;
|
|
1986
|
+
if (err instanceof ACPSessionError && err.kind === "aborted") {
|
|
1987
|
+
this.cancelLateSession(createPromise);
|
|
1988
|
+
return emptyRunResult("cancelled");
|
|
1989
|
+
}
|
|
1990
|
+
throw err;
|
|
1991
|
+
}
|
|
1992
|
+
signal.removeEventListener("abort", onCreateAbort);
|
|
1993
|
+
rejectCreate = void 0;
|
|
1994
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
1995
|
+
signal.removeEventListener("abort", onAbort);
|
|
1996
|
+
this.promptCallbackAbort?.abort();
|
|
1997
|
+
this.promptCallbackAbort = null;
|
|
1998
|
+
throw new ACPSessionError("protocol_error", "session/new returned no session id");
|
|
1999
|
+
}
|
|
2000
|
+
this.sessionId = sessionId;
|
|
1946
2001
|
}
|
|
1947
2002
|
if (signal.aborted) {
|
|
1948
2003
|
return emptyRunResult("cancelled");
|
|
@@ -1950,42 +2005,46 @@ var ACPSession = class _ACPSession {
|
|
|
1950
2005
|
this.resetScratch();
|
|
1951
2006
|
this.progressHandler = onProgress ?? null;
|
|
1952
2007
|
const promptId = this.allocId();
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
"
|
|
1956
|
-
{
|
|
1957
|
-
sessionId: this.sessionId,
|
|
1958
|
-
prompt: blocks
|
|
1959
|
-
},
|
|
1960
|
-
this.timeoutMs
|
|
1961
|
-
);
|
|
1962
|
-
let cancelled = false;
|
|
1963
|
-
this.promptCallbackAbort = new AbortController();
|
|
1964
|
-
const onAbort = () => {
|
|
1965
|
-
cancelled = true;
|
|
1966
|
-
this.promptCallbackAbort?.abort();
|
|
1967
|
-
this.transport.send({
|
|
1968
|
-
jsonrpc: "2.0",
|
|
1969
|
-
method: "session/cancel",
|
|
1970
|
-
params: { sessionId: this.sessionId }
|
|
1971
|
-
}).catch(() => {
|
|
1972
|
-
});
|
|
2008
|
+
let rejectTurn;
|
|
2009
|
+
const onTurnAbort = () => {
|
|
2010
|
+
rejectTurn?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
|
|
1973
2011
|
};
|
|
1974
|
-
signal.addEventListener("abort",
|
|
2012
|
+
signal.addEventListener("abort", onTurnAbort, { once: true });
|
|
2013
|
+
const turnPromise = Promise.race([
|
|
2014
|
+
this.sendRequest(
|
|
2015
|
+
promptId,
|
|
2016
|
+
"session/prompt",
|
|
2017
|
+
{
|
|
2018
|
+
sessionId: this.sessionId,
|
|
2019
|
+
prompt: blocks
|
|
2020
|
+
},
|
|
2021
|
+
this.timeoutMs
|
|
2022
|
+
),
|
|
2023
|
+
new Promise((_, reject) => {
|
|
2024
|
+
rejectTurn = reject;
|
|
2025
|
+
})
|
|
2026
|
+
]);
|
|
1975
2027
|
this.state = "prompting";
|
|
1976
2028
|
let response;
|
|
1977
2029
|
try {
|
|
1978
2030
|
response = await turnPromise;
|
|
1979
2031
|
} catch (err) {
|
|
1980
2032
|
this.state = "done";
|
|
1981
|
-
|
|
1982
|
-
if (cancelled ||
|
|
1983
|
-
|
|
2033
|
+
const abortedKind = err instanceof ACPSessionError && err.kind === "aborted";
|
|
2034
|
+
if (cancelled || abortedKind) {
|
|
2035
|
+
return emptyRunResult("cancelled");
|
|
1984
2036
|
}
|
|
1985
2037
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2038
|
+
if (signal.aborted) {
|
|
2039
|
+
throw new ACPSessionError("aborted", "prompt was aborted by the parent", err);
|
|
2040
|
+
}
|
|
1986
2041
|
throw new ACPSessionError("prompt_failed", `session/prompt failed: ${msg}`, err);
|
|
1987
2042
|
} finally {
|
|
1988
2043
|
signal.removeEventListener("abort", onAbort);
|
|
2044
|
+
signal.removeEventListener("abort", onTurnAbort);
|
|
2045
|
+
signal.removeEventListener("abort", onCreateAbort);
|
|
2046
|
+
rejectTurn = void 0;
|
|
2047
|
+
rejectCreate = void 0;
|
|
1989
2048
|
this.promptCallbackAbort?.abort();
|
|
1990
2049
|
this.promptCallbackAbort = null;
|
|
1991
2050
|
this.progressHandler = null;
|
|
@@ -2007,6 +2066,61 @@ var ACPSession = class _ACPSession {
|
|
|
2007
2066
|
thoughts: this.scratch.thoughts
|
|
2008
2067
|
};
|
|
2009
2068
|
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Best-effort cancel for a session the server confirmed after we already
|
|
2071
|
+
* stopped waiting (the abort-during-creation race). `session/cancel` is a
|
|
2072
|
+
* JSON-RPC notification — the server sends no response, so this is a bare
|
|
2073
|
+
* transport send bounded by a timer rather than sendRequest, whose
|
|
2074
|
+
* pending-tracking would just expire waiting for a reply that never
|
|
2075
|
+
* comes. A failure or timeout is surfaced on the warn channel instead of
|
|
2076
|
+
* being silently swallowed.
|
|
2077
|
+
*/
|
|
2078
|
+
cancelLateSession(createPromise) {
|
|
2079
|
+
createPromise.then(async (lateId) => {
|
|
2080
|
+
try {
|
|
2081
|
+
await new Promise((resolve3, reject) => {
|
|
2082
|
+
const timer = setTimeout(() => {
|
|
2083
|
+
reject(new Error("late session/cancel send timed out"));
|
|
2084
|
+
}, LATE_CANCEL_SEND_TIMEOUT_MS);
|
|
2085
|
+
Promise.resolve(
|
|
2086
|
+
this.transport.send({
|
|
2087
|
+
jsonrpc: "2.0",
|
|
2088
|
+
method: "session/cancel",
|
|
2089
|
+
params: { sessionId: lateId }
|
|
2090
|
+
})
|
|
2091
|
+
).then(
|
|
2092
|
+
() => {
|
|
2093
|
+
clearTimeout(timer);
|
|
2094
|
+
resolve3();
|
|
2095
|
+
},
|
|
2096
|
+
(sendErr) => {
|
|
2097
|
+
clearTimeout(timer);
|
|
2098
|
+
reject(sendErr instanceof Error ? sendErr : new Error(String(sendErr)));
|
|
2099
|
+
}
|
|
2100
|
+
);
|
|
2101
|
+
});
|
|
2102
|
+
} catch (err) {
|
|
2103
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2104
|
+
console.warn(
|
|
2105
|
+
JSON.stringify({
|
|
2106
|
+
level: "warn",
|
|
2107
|
+
event: "acp_session.late_cancel_failed",
|
|
2108
|
+
sessionId: lateId,
|
|
2109
|
+
message
|
|
2110
|
+
})
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
}).catch((reason) => {
|
|
2114
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
2115
|
+
console.warn(
|
|
2116
|
+
JSON.stringify({
|
|
2117
|
+
level: "warn",
|
|
2118
|
+
event: "acp_session.late_cancel_failed",
|
|
2119
|
+
reason: message
|
|
2120
|
+
})
|
|
2121
|
+
);
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2010
2124
|
async closeSession() {
|
|
2011
2125
|
if (!this.sessionId) return;
|
|
2012
2126
|
const sid = this.sessionId;
|
package/dist/index.js
CHANGED
|
@@ -132,7 +132,7 @@ function errorToJsonRpc(err) {
|
|
|
132
132
|
const message = err instanceof Error ? err.message : String(err);
|
|
133
133
|
return { code: -32603, message };
|
|
134
134
|
}
|
|
135
|
-
function createRunTurnApi(sessionId, clientCapabilities, request) {
|
|
135
|
+
function createRunTurnApi(sessionId, clientCapabilities, request, sendSessionUpdate) {
|
|
136
136
|
return {
|
|
137
137
|
clientCapabilities,
|
|
138
138
|
requestPermission: async (req) => {
|
|
@@ -176,7 +176,8 @@ function createRunTurnApi(sessionId, clientCapabilities, request) {
|
|
|
176
176
|
} catch {
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
-
}
|
|
179
|
+
},
|
|
180
|
+
...sendSessionUpdate ? { sendSessionUpdate } : {}
|
|
180
181
|
};
|
|
181
182
|
}
|
|
182
183
|
function buildInitializeResult(agentName, modes, configOptions) {
|
|
@@ -438,7 +439,12 @@ async function handleSessionPromptOp(ctx, id, params) {
|
|
|
438
439
|
const api = createRunTurnApi(
|
|
439
440
|
sessionId,
|
|
440
441
|
ctx.clientCapabilities ?? {},
|
|
441
|
-
(method, req) => ctx.request(method, req)
|
|
442
|
+
(method, req) => ctx.request(method, req),
|
|
443
|
+
async (update) => {
|
|
444
|
+
if (ctx.sessions.get(sessionId) !== session) return false;
|
|
445
|
+
await ctx.sendNotification({ sessionId, update });
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
442
448
|
);
|
|
443
449
|
let result;
|
|
444
450
|
const pendingNotifications = [];
|
|
@@ -3044,6 +3050,7 @@ function finitePositiveLimit(value, fallback) {
|
|
|
3044
3050
|
}
|
|
3045
3051
|
|
|
3046
3052
|
// src/client/acp-session.ts
|
|
3053
|
+
var LATE_CANCEL_SEND_TIMEOUT_MS = 1e4;
|
|
3047
3054
|
var ACPSession = class _ACPSession {
|
|
3048
3055
|
transport;
|
|
3049
3056
|
fileServer;
|
|
@@ -3344,8 +3351,62 @@ var ACPSession = class _ACPSession {
|
|
|
3344
3351
|
if (signal.aborted) {
|
|
3345
3352
|
return emptyRunResult("cancelled");
|
|
3346
3353
|
}
|
|
3354
|
+
let cancelled = false;
|
|
3355
|
+
this.promptCallbackAbort = new AbortController();
|
|
3356
|
+
const onAbort = () => {
|
|
3357
|
+
cancelled = true;
|
|
3358
|
+
this.promptCallbackAbort?.abort();
|
|
3359
|
+
if (this.sessionId) {
|
|
3360
|
+
this.transport.send({
|
|
3361
|
+
jsonrpc: "2.0",
|
|
3362
|
+
method: "session/cancel",
|
|
3363
|
+
params: { sessionId: this.sessionId }
|
|
3364
|
+
}).catch(() => {
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3369
|
+
let rejectCreate;
|
|
3370
|
+
const onCreateAbort = () => {
|
|
3371
|
+
signal.removeEventListener("abort", onAbort);
|
|
3372
|
+
rejectCreate?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
|
|
3373
|
+
};
|
|
3347
3374
|
if (!this.sessionId) {
|
|
3348
|
-
|
|
3375
|
+
let sessionId;
|
|
3376
|
+
const createPromise = this.createSessionWithAuth();
|
|
3377
|
+
try {
|
|
3378
|
+
sessionId = await Promise.race([
|
|
3379
|
+
createPromise,
|
|
3380
|
+
new Promise((_, reject) => {
|
|
3381
|
+
rejectCreate = reject;
|
|
3382
|
+
if (signal.aborted) {
|
|
3383
|
+
reject(new ACPSessionError("aborted", "prompt was aborted by the parent"));
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
signal.addEventListener("abort", onCreateAbort, { once: true });
|
|
3387
|
+
})
|
|
3388
|
+
]);
|
|
3389
|
+
} catch (err) {
|
|
3390
|
+
signal.removeEventListener("abort", onAbort);
|
|
3391
|
+
signal.removeEventListener("abort", onCreateAbort);
|
|
3392
|
+
rejectCreate = void 0;
|
|
3393
|
+
this.promptCallbackAbort?.abort();
|
|
3394
|
+
this.promptCallbackAbort = null;
|
|
3395
|
+
if (err instanceof ACPSessionError && err.kind === "aborted") {
|
|
3396
|
+
this.cancelLateSession(createPromise);
|
|
3397
|
+
return emptyRunResult("cancelled");
|
|
3398
|
+
}
|
|
3399
|
+
throw err;
|
|
3400
|
+
}
|
|
3401
|
+
signal.removeEventListener("abort", onCreateAbort);
|
|
3402
|
+
rejectCreate = void 0;
|
|
3403
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
3404
|
+
signal.removeEventListener("abort", onAbort);
|
|
3405
|
+
this.promptCallbackAbort?.abort();
|
|
3406
|
+
this.promptCallbackAbort = null;
|
|
3407
|
+
throw new ACPSessionError("protocol_error", "session/new returned no session id");
|
|
3408
|
+
}
|
|
3409
|
+
this.sessionId = sessionId;
|
|
3349
3410
|
}
|
|
3350
3411
|
if (signal.aborted) {
|
|
3351
3412
|
return emptyRunResult("cancelled");
|
|
@@ -3353,42 +3414,46 @@ var ACPSession = class _ACPSession {
|
|
|
3353
3414
|
this.resetScratch();
|
|
3354
3415
|
this.progressHandler = onProgress ?? null;
|
|
3355
3416
|
const promptId = this.allocId();
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
"
|
|
3359
|
-
{
|
|
3360
|
-
sessionId: this.sessionId,
|
|
3361
|
-
prompt: blocks
|
|
3362
|
-
},
|
|
3363
|
-
this.timeoutMs
|
|
3364
|
-
);
|
|
3365
|
-
let cancelled = false;
|
|
3366
|
-
this.promptCallbackAbort = new AbortController();
|
|
3367
|
-
const onAbort = () => {
|
|
3368
|
-
cancelled = true;
|
|
3369
|
-
this.promptCallbackAbort?.abort();
|
|
3370
|
-
this.transport.send({
|
|
3371
|
-
jsonrpc: "2.0",
|
|
3372
|
-
method: "session/cancel",
|
|
3373
|
-
params: { sessionId: this.sessionId }
|
|
3374
|
-
}).catch(() => {
|
|
3375
|
-
});
|
|
3417
|
+
let rejectTurn;
|
|
3418
|
+
const onTurnAbort = () => {
|
|
3419
|
+
rejectTurn?.(new ACPSessionError("aborted", "prompt was aborted by the parent"));
|
|
3376
3420
|
};
|
|
3377
|
-
signal.addEventListener("abort",
|
|
3421
|
+
signal.addEventListener("abort", onTurnAbort, { once: true });
|
|
3422
|
+
const turnPromise = Promise.race([
|
|
3423
|
+
this.sendRequest(
|
|
3424
|
+
promptId,
|
|
3425
|
+
"session/prompt",
|
|
3426
|
+
{
|
|
3427
|
+
sessionId: this.sessionId,
|
|
3428
|
+
prompt: blocks
|
|
3429
|
+
},
|
|
3430
|
+
this.timeoutMs
|
|
3431
|
+
),
|
|
3432
|
+
new Promise((_, reject) => {
|
|
3433
|
+
rejectTurn = reject;
|
|
3434
|
+
})
|
|
3435
|
+
]);
|
|
3378
3436
|
this.state = "prompting";
|
|
3379
3437
|
let response;
|
|
3380
3438
|
try {
|
|
3381
3439
|
response = await turnPromise;
|
|
3382
3440
|
} catch (err) {
|
|
3383
3441
|
this.state = "done";
|
|
3384
|
-
|
|
3385
|
-
if (cancelled ||
|
|
3386
|
-
|
|
3442
|
+
const abortedKind = err instanceof ACPSessionError && err.kind === "aborted";
|
|
3443
|
+
if (cancelled || abortedKind) {
|
|
3444
|
+
return emptyRunResult("cancelled");
|
|
3387
3445
|
}
|
|
3388
3446
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3447
|
+
if (signal.aborted) {
|
|
3448
|
+
throw new ACPSessionError("aborted", "prompt was aborted by the parent", err);
|
|
3449
|
+
}
|
|
3389
3450
|
throw new ACPSessionError("prompt_failed", `session/prompt failed: ${msg}`, err);
|
|
3390
3451
|
} finally {
|
|
3391
3452
|
signal.removeEventListener("abort", onAbort);
|
|
3453
|
+
signal.removeEventListener("abort", onTurnAbort);
|
|
3454
|
+
signal.removeEventListener("abort", onCreateAbort);
|
|
3455
|
+
rejectTurn = void 0;
|
|
3456
|
+
rejectCreate = void 0;
|
|
3392
3457
|
this.promptCallbackAbort?.abort();
|
|
3393
3458
|
this.promptCallbackAbort = null;
|
|
3394
3459
|
this.progressHandler = null;
|
|
@@ -3410,6 +3475,61 @@ var ACPSession = class _ACPSession {
|
|
|
3410
3475
|
thoughts: this.scratch.thoughts
|
|
3411
3476
|
};
|
|
3412
3477
|
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Best-effort cancel for a session the server confirmed after we already
|
|
3480
|
+
* stopped waiting (the abort-during-creation race). `session/cancel` is a
|
|
3481
|
+
* JSON-RPC notification — the server sends no response, so this is a bare
|
|
3482
|
+
* transport send bounded by a timer rather than sendRequest, whose
|
|
3483
|
+
* pending-tracking would just expire waiting for a reply that never
|
|
3484
|
+
* comes. A failure or timeout is surfaced on the warn channel instead of
|
|
3485
|
+
* being silently swallowed.
|
|
3486
|
+
*/
|
|
3487
|
+
cancelLateSession(createPromise) {
|
|
3488
|
+
createPromise.then(async (lateId) => {
|
|
3489
|
+
try {
|
|
3490
|
+
await new Promise((resolve4, reject) => {
|
|
3491
|
+
const timer = setTimeout(() => {
|
|
3492
|
+
reject(new Error("late session/cancel send timed out"));
|
|
3493
|
+
}, LATE_CANCEL_SEND_TIMEOUT_MS);
|
|
3494
|
+
Promise.resolve(
|
|
3495
|
+
this.transport.send({
|
|
3496
|
+
jsonrpc: "2.0",
|
|
3497
|
+
method: "session/cancel",
|
|
3498
|
+
params: { sessionId: lateId }
|
|
3499
|
+
})
|
|
3500
|
+
).then(
|
|
3501
|
+
() => {
|
|
3502
|
+
clearTimeout(timer);
|
|
3503
|
+
resolve4();
|
|
3504
|
+
},
|
|
3505
|
+
(sendErr) => {
|
|
3506
|
+
clearTimeout(timer);
|
|
3507
|
+
reject(sendErr instanceof Error ? sendErr : new Error(String(sendErr)));
|
|
3508
|
+
}
|
|
3509
|
+
);
|
|
3510
|
+
});
|
|
3511
|
+
} catch (err) {
|
|
3512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3513
|
+
console.warn(
|
|
3514
|
+
JSON.stringify({
|
|
3515
|
+
level: "warn",
|
|
3516
|
+
event: "acp_session.late_cancel_failed",
|
|
3517
|
+
sessionId: lateId,
|
|
3518
|
+
message
|
|
3519
|
+
})
|
|
3520
|
+
);
|
|
3521
|
+
}
|
|
3522
|
+
}).catch((reason) => {
|
|
3523
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
3524
|
+
console.warn(
|
|
3525
|
+
JSON.stringify({
|
|
3526
|
+
level: "warn",
|
|
3527
|
+
event: "acp_session.late_cancel_failed",
|
|
3528
|
+
reason: message
|
|
3529
|
+
})
|
|
3530
|
+
);
|
|
3531
|
+
});
|
|
3532
|
+
}
|
|
3413
3533
|
async closeSession() {
|
|
3414
3534
|
if (!this.sessionId) return;
|
|
3415
3535
|
const sid = this.sessionId;
|
|
@@ -136,7 +136,7 @@ function errorToJsonRpc(err) {
|
|
|
136
136
|
const message = err instanceof Error ? err.message : String(err);
|
|
137
137
|
return { code: -32603, message };
|
|
138
138
|
}
|
|
139
|
-
function createRunTurnApi(sessionId, clientCapabilities, request) {
|
|
139
|
+
function createRunTurnApi(sessionId, clientCapabilities, request, sendSessionUpdate) {
|
|
140
140
|
return {
|
|
141
141
|
clientCapabilities,
|
|
142
142
|
requestPermission: async (req) => {
|
|
@@ -180,7 +180,8 @@ function createRunTurnApi(sessionId, clientCapabilities, request) {
|
|
|
180
180
|
} catch {
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
-
}
|
|
183
|
+
},
|
|
184
|
+
...sendSessionUpdate ? { sendSessionUpdate } : {}
|
|
184
185
|
};
|
|
185
186
|
}
|
|
186
187
|
function buildInitializeResult(agentName, modes, configOptions) {
|
|
@@ -442,7 +443,12 @@ async function handleSessionPromptOp(ctx, id, params) {
|
|
|
442
443
|
const api = createRunTurnApi(
|
|
443
444
|
sessionId,
|
|
444
445
|
ctx.clientCapabilities ?? {},
|
|
445
|
-
(method, req) => ctx.request(method, req)
|
|
446
|
+
(method, req) => ctx.request(method, req),
|
|
447
|
+
async (update) => {
|
|
448
|
+
if (ctx.sessions.get(sessionId) !== session) return false;
|
|
449
|
+
await ctx.sendNotification({ sessionId, update });
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
446
452
|
);
|
|
447
453
|
let result;
|
|
448
454
|
const pendingNotifications = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wrongstack/acp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "ACP (Agent Client Protocol) integration for WrongStack — client + agent support",
|
|
6
6
|
"keywords": [
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
],
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@agentclientprotocol/sdk": "^1.4.0",
|
|
55
|
-
"@wrongstack/core": "1.0.
|
|
56
|
-
"@wrongstack/primitives": "1.0.
|
|
55
|
+
"@wrongstack/core": "1.0.10",
|
|
56
|
+
"@wrongstack/primitives": "1.0.10"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@types/node": "^26.
|
|
59
|
+
"@types/node": "^26.5.1",
|
|
60
60
|
"typescript": "^7.0.2",
|
|
61
|
-
"vitest": "^
|
|
61
|
+
"vitest": "^5.0.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|