gsd-pi 2.78.1-dev.8a893322c → 2.78.1-dev.a7b6e59b7
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/cli-auto-routing.d.ts +1 -0
- package/dist/cli-auto-routing.js +5 -0
- package/dist/cli.js +5 -14
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/run-unit.js +23 -11
- package/dist/resources/extensions/gsd/auto-direct-dispatch.js +55 -21
- package/dist/resources/extensions/gsd/auto-prompts.js +6 -0
- package/dist/resources/extensions/gsd/auto-worktree.js +15 -0
- package/dist/resources/extensions/gsd/auto.js +25 -9
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
- package/dist/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
- package/dist/resources/extensions/gsd/worktree-resolver.js +24 -0
- package/dist/resources/skills/lint/SKILL.md +4 -0
- package/dist/resources/skills/review/SKILL.md +4 -0
- package/dist/resources/skills/test/SKILL.md +3 -0
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +278 -0
- package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +7 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +125 -55
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +319 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +128 -59
- package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/run-unit.ts +23 -11
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +60 -24
- package/src/resources/extensions/gsd/auto-prompts.ts +6 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +15 -0
- package/src/resources/extensions/gsd/auto.ts +23 -6
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
- package/src/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +8 -2
- package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +12 -6
- package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +235 -0
- package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +85 -0
- package/src/resources/extensions/gsd/worktree-resolver.ts +24 -0
- package/src/resources/skills/lint/SKILL.md +4 -0
- package/src/resources/skills/review/SKILL.md +4 -0
- package/src/resources/skills/test/SKILL.md +3 -0
- /package/dist/web/standalone/.next/static/{QK8fABiGPmonfTgboN0Y9 → GlYncvckBGG33CSoJaSnB}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{QK8fABiGPmonfTgboN0Y9 → GlYncvckBGG33CSoJaSnB}/_ssgManifest.js +0 -0
|
@@ -77,6 +77,38 @@ function recordCallOrder<O extends object>(
|
|
|
77
77
|
return order;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function makeAssistantMessage(text: string) {
|
|
81
|
+
return {
|
|
82
|
+
role: "assistant",
|
|
83
|
+
content: [{ type: "text", text }],
|
|
84
|
+
usage: {
|
|
85
|
+
input: 1,
|
|
86
|
+
output: 1,
|
|
87
|
+
cacheRead: 0,
|
|
88
|
+
cacheWrite: 0,
|
|
89
|
+
total: 2,
|
|
90
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
91
|
+
},
|
|
92
|
+
stopReason: "stop",
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
} as any;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function installAgentEndSessionTransition(
|
|
98
|
+
session: AgentSession,
|
|
99
|
+
transition: () => Promise<unknown>,
|
|
100
|
+
): void {
|
|
101
|
+
(session as any)._extensionRunner = {
|
|
102
|
+
hasHandlers: () => false,
|
|
103
|
+
emit: async (event: any) => {
|
|
104
|
+
if (event.type === "agent_end") {
|
|
105
|
+
await transition();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
emitStop: async () => {},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
80
112
|
describe("#4243 — abort() must run before _disconnectFromAgent()", () => {
|
|
81
113
|
beforeEach(() => {
|
|
82
114
|
testDir = mkdtempSync(join(tmpdir(), "agent-session-abort-"));
|
|
@@ -106,6 +138,293 @@ describe("#4243 — abort() must run before _disconnectFromAgent()", () => {
|
|
|
106
138
|
);
|
|
107
139
|
});
|
|
108
140
|
|
|
141
|
+
it("newSession() waits instead of aborting while agent_end processing is still streaming", async () => {
|
|
142
|
+
const session = await createSession();
|
|
143
|
+
const order: string[] = [];
|
|
144
|
+
let releaseIdle!: () => void;
|
|
145
|
+
const idle = new Promise<void>((resolve) => {
|
|
146
|
+
releaseIdle = resolve;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
(session as any)._processingAgentEnd = true;
|
|
150
|
+
(session as any).agent.state.isStreaming = true;
|
|
151
|
+
(session as any).agent.waitForIdle = () => {
|
|
152
|
+
order.push("waitForIdle");
|
|
153
|
+
return idle;
|
|
154
|
+
};
|
|
155
|
+
(session as any).abort = async () => {
|
|
156
|
+
order.push("abort");
|
|
157
|
+
};
|
|
158
|
+
const originalDisconnect = (session as any)._disconnectFromAgent.bind(session);
|
|
159
|
+
(session as any)._disconnectFromAgent = () => {
|
|
160
|
+
order.push("_disconnectFromAgent");
|
|
161
|
+
originalDisconnect();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const pendingNewSession = session.newSession();
|
|
165
|
+
await Promise.resolve();
|
|
166
|
+
assert.deepEqual(order, ["waitForIdle"]);
|
|
167
|
+
assert.equal(order.includes("abort"), false);
|
|
168
|
+
|
|
169
|
+
releaseIdle();
|
|
170
|
+
const ok = await pendingNewSession;
|
|
171
|
+
assert.equal(ok, true);
|
|
172
|
+
assert.deepEqual(order, ["waitForIdle", "_disconnectFromAgent"]);
|
|
173
|
+
assert.equal(order.includes("abort"), false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("newSession() waits during agent_end processing even once already idle", async () => {
|
|
177
|
+
const session = await createSession();
|
|
178
|
+
const order: string[] = [];
|
|
179
|
+
|
|
180
|
+
(session as any)._processingAgentEnd = true;
|
|
181
|
+
(session as any).agent.state.isStreaming = false;
|
|
182
|
+
(session as any).agent.waitForIdle = async () => {
|
|
183
|
+
order.push("waitForIdle");
|
|
184
|
+
};
|
|
185
|
+
(session as any).abort = async () => {
|
|
186
|
+
order.push("abort");
|
|
187
|
+
};
|
|
188
|
+
const originalDisconnect = (session as any)._disconnectFromAgent.bind(session);
|
|
189
|
+
(session as any)._disconnectFromAgent = () => {
|
|
190
|
+
order.push("_disconnectFromAgent");
|
|
191
|
+
originalDisconnect();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const ok = await session.newSession();
|
|
195
|
+
assert.equal(ok, true);
|
|
196
|
+
assert.deepEqual(order, ["waitForIdle", "_disconnectFromAgent"]);
|
|
197
|
+
assert.equal(order.includes("abort"), false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("abort() marks synthetic agent_end processing while extension handlers run", async () => {
|
|
201
|
+
const session = await createSession();
|
|
202
|
+
const observedProcessingStates: boolean[] = [];
|
|
203
|
+
|
|
204
|
+
(session as any).agent.abort = () => {};
|
|
205
|
+
(session as any).agent.waitForIdle = async () => {};
|
|
206
|
+
(session as any)._extensionRunner = {
|
|
207
|
+
emit: async (event: any) => {
|
|
208
|
+
if (event.type === "agent_end") {
|
|
209
|
+
observedProcessingStates.push((session as any)._processingAgentEnd);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
emitStop: async () => {
|
|
213
|
+
observedProcessingStates.push((session as any)._processingAgentEnd);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
await session.abort();
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(observedProcessingStates, [true, true]);
|
|
220
|
+
assert.equal((session as any)._processingAgentEnd, false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("newSession() during agent_end preserves the previous session for resume", async () => {
|
|
224
|
+
const session = await createSession({ persistSessions: true });
|
|
225
|
+
const previousSessionFile = session.sessionFile;
|
|
226
|
+
assert.ok(previousSessionFile, "need a persisted session file");
|
|
227
|
+
|
|
228
|
+
session.sessionManager.appendMessage({
|
|
229
|
+
role: "user",
|
|
230
|
+
content: [{ type: "text", text: "persisted prompt" }],
|
|
231
|
+
} as any);
|
|
232
|
+
session.sessionManager.appendMessage({
|
|
233
|
+
role: "assistant",
|
|
234
|
+
content: [{ type: "text", text: "persisted response" }],
|
|
235
|
+
usage: {
|
|
236
|
+
input: 1,
|
|
237
|
+
output: 1,
|
|
238
|
+
cacheRead: 0,
|
|
239
|
+
cacheWrite: 0,
|
|
240
|
+
total: 2,
|
|
241
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
242
|
+
},
|
|
243
|
+
stopReason: "stop",
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
} as any);
|
|
246
|
+
session.agent.replaceMessages(session.sessionManager.buildSessionContext().messages);
|
|
247
|
+
|
|
248
|
+
(session as any)._processingAgentEnd = true;
|
|
249
|
+
(session as any).agent.waitForIdle = async () => {};
|
|
250
|
+
|
|
251
|
+
const ok = await session.newSession();
|
|
252
|
+
assert.equal(ok, true);
|
|
253
|
+
assert.notEqual(session.sessionFile, previousSessionFile);
|
|
254
|
+
assert.deepEqual(session.messages, []);
|
|
255
|
+
|
|
256
|
+
(session as any)._processingAgentEnd = false;
|
|
257
|
+
const switched = await session.switchSession(previousSessionFile);
|
|
258
|
+
assert.equal(switched, true);
|
|
259
|
+
|
|
260
|
+
const restoredText = session.messages
|
|
261
|
+
.flatMap((message: any) => message.content ?? [])
|
|
262
|
+
.filter((part: any) => part.type === "text")
|
|
263
|
+
.map((part: any) => part.text);
|
|
264
|
+
assert.deepEqual(restoredText, ["persisted prompt", "persisted response"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("switchSession() waits instead of aborting while agent_end processing is still streaming", async () => {
|
|
268
|
+
const session = await createSession({ persistSessions: true });
|
|
269
|
+
const previousSessionFile = session.sessionFile;
|
|
270
|
+
assert.ok(previousSessionFile, "need a persisted session file");
|
|
271
|
+
|
|
272
|
+
session.sessionManager.appendMessage({
|
|
273
|
+
role: "user",
|
|
274
|
+
content: [{ type: "text", text: "switch persisted prompt" }],
|
|
275
|
+
} as any);
|
|
276
|
+
session.sessionManager.appendMessage({
|
|
277
|
+
role: "assistant",
|
|
278
|
+
content: [{ type: "text", text: "switch persisted response" }],
|
|
279
|
+
usage: {
|
|
280
|
+
input: 1,
|
|
281
|
+
output: 1,
|
|
282
|
+
cacheRead: 0,
|
|
283
|
+
cacheWrite: 0,
|
|
284
|
+
total: 2,
|
|
285
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
286
|
+
},
|
|
287
|
+
stopReason: "stop",
|
|
288
|
+
timestamp: Date.now(),
|
|
289
|
+
} as any);
|
|
290
|
+
session.agent.replaceMessages(session.sessionManager.buildSessionContext().messages);
|
|
291
|
+
|
|
292
|
+
const ok = await session.newSession();
|
|
293
|
+
assert.equal(ok, true);
|
|
294
|
+
const activeSessionFile = session.sessionFile;
|
|
295
|
+
assert.ok(activeSessionFile, "need an active session file");
|
|
296
|
+
assert.notEqual(activeSessionFile, previousSessionFile);
|
|
297
|
+
assert.deepEqual(session.messages, []);
|
|
298
|
+
|
|
299
|
+
const order: string[] = [];
|
|
300
|
+
let releaseIdle!: () => void;
|
|
301
|
+
const idle = new Promise<void>((resolve) => {
|
|
302
|
+
releaseIdle = resolve;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
(session as any)._processingAgentEnd = true;
|
|
306
|
+
(session as any).agent.state.isStreaming = true;
|
|
307
|
+
(session as any).agent.waitForIdle = () => {
|
|
308
|
+
order.push("waitForIdle");
|
|
309
|
+
return idle;
|
|
310
|
+
};
|
|
311
|
+
(session as any).abort = async () => {
|
|
312
|
+
order.push("abort");
|
|
313
|
+
};
|
|
314
|
+
const originalDisconnect = (session as any)._disconnectFromAgent.bind(session);
|
|
315
|
+
(session as any)._disconnectFromAgent = () => {
|
|
316
|
+
order.push("_disconnectFromAgent");
|
|
317
|
+
originalDisconnect();
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const pendingSwitch = session.switchSession(previousSessionFile);
|
|
321
|
+
await Promise.resolve();
|
|
322
|
+
assert.deepEqual(order, ["waitForIdle"]);
|
|
323
|
+
assert.equal(order.includes("abort"), false);
|
|
324
|
+
assert.equal(session.sessionFile, activeSessionFile);
|
|
325
|
+
assert.deepEqual(session.messages, []);
|
|
326
|
+
|
|
327
|
+
releaseIdle();
|
|
328
|
+
const switched = await pendingSwitch;
|
|
329
|
+
assert.equal(switched, true);
|
|
330
|
+
assert.deepEqual(order, ["waitForIdle", "_disconnectFromAgent"]);
|
|
331
|
+
assert.equal(order.includes("abort"), false);
|
|
332
|
+
assert.equal(session.sessionFile, previousSessionFile);
|
|
333
|
+
|
|
334
|
+
const restoredText = session.messages
|
|
335
|
+
.flatMap((message: any) => message.content ?? [])
|
|
336
|
+
.filter((part: any) => part.type === "text")
|
|
337
|
+
.map((part: any) => part.text);
|
|
338
|
+
assert.deepEqual(restoredText, ["switch persisted prompt", "switch persisted response"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("newSession() during agent_end skips stale post-handlers after the transition starts", async () => {
|
|
342
|
+
const session = await createSession();
|
|
343
|
+
const assistantMessage = makeAssistantMessage("old response");
|
|
344
|
+
let compactionChecks = 0;
|
|
345
|
+
let listenerAgentEnds = 0;
|
|
346
|
+
|
|
347
|
+
(session as any)._lastAssistantMessage = assistantMessage;
|
|
348
|
+
(session as any)._compactionOrchestrator.checkCompaction = async () => {
|
|
349
|
+
compactionChecks++;
|
|
350
|
+
};
|
|
351
|
+
session.subscribe((event: any) => {
|
|
352
|
+
if (event.type === "agent_end") listenerAgentEnds++;
|
|
353
|
+
});
|
|
354
|
+
installAgentEndSessionTransition(session, () => session.newSession());
|
|
355
|
+
|
|
356
|
+
await (session as any)._processAgentEvent({
|
|
357
|
+
type: "agent_end",
|
|
358
|
+
messages: [assistantMessage],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
assert.equal(compactionChecks, 0);
|
|
362
|
+
assert.equal(listenerAgentEnds, 0);
|
|
363
|
+
assert.equal((session as any)._lastAssistantMessage, undefined);
|
|
364
|
+
assert.equal((session as any)._sessionSwitchPending, false);
|
|
365
|
+
assert.equal((session as any)._sessionTransitionStartedDuringAgentEnd, false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("switchSession() during agent_end skips stale post-handlers after the transition starts", async () => {
|
|
369
|
+
const session = await createSession({ persistSessions: true });
|
|
370
|
+
const previousSessionFile = session.sessionFile;
|
|
371
|
+
assert.ok(previousSessionFile, "need a persisted session file");
|
|
372
|
+
|
|
373
|
+
const ok = await session.newSession();
|
|
374
|
+
assert.equal(ok, true);
|
|
375
|
+
assert.notEqual(session.sessionFile, previousSessionFile);
|
|
376
|
+
|
|
377
|
+
const assistantMessage = makeAssistantMessage("old switch response");
|
|
378
|
+
let compactionChecks = 0;
|
|
379
|
+
let listenerAgentEnds = 0;
|
|
380
|
+
|
|
381
|
+
(session as any)._lastAssistantMessage = assistantMessage;
|
|
382
|
+
(session as any)._compactionOrchestrator.checkCompaction = async () => {
|
|
383
|
+
compactionChecks++;
|
|
384
|
+
};
|
|
385
|
+
session.subscribe((event: any) => {
|
|
386
|
+
if (event.type === "agent_end") listenerAgentEnds++;
|
|
387
|
+
});
|
|
388
|
+
installAgentEndSessionTransition(session, () => session.switchSession(previousSessionFile));
|
|
389
|
+
|
|
390
|
+
await (session as any)._processAgentEvent({
|
|
391
|
+
type: "agent_end",
|
|
392
|
+
messages: [assistantMessage],
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
assert.equal(session.sessionFile, previousSessionFile);
|
|
396
|
+
assert.equal(compactionChecks, 0);
|
|
397
|
+
assert.equal(listenerAgentEnds, 0);
|
|
398
|
+
assert.equal((session as any)._lastAssistantMessage, undefined);
|
|
399
|
+
assert.equal((session as any)._sessionSwitchPending, false);
|
|
400
|
+
assert.equal((session as any)._sessionTransitionStartedDuringAgentEnd, false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("agent_end post-handlers bail while a session switch is pending", async () => {
|
|
404
|
+
const session = await createSession();
|
|
405
|
+
const assistantMessage = makeAssistantMessage("old pending response");
|
|
406
|
+
let compactionChecks = 0;
|
|
407
|
+
let listenerAgentEnds = 0;
|
|
408
|
+
|
|
409
|
+
(session as any)._lastAssistantMessage = assistantMessage;
|
|
410
|
+
(session as any)._sessionSwitchPending = true;
|
|
411
|
+
(session as any)._compactionOrchestrator.checkCompaction = async () => {
|
|
412
|
+
compactionChecks++;
|
|
413
|
+
};
|
|
414
|
+
session.subscribe((event: any) => {
|
|
415
|
+
if (event.type === "agent_end") listenerAgentEnds++;
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await (session as any)._processAgentEvent({
|
|
419
|
+
type: "agent_end",
|
|
420
|
+
messages: [assistantMessage],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
assert.equal(compactionChecks, 0);
|
|
424
|
+
assert.equal(listenerAgentEnds, 1);
|
|
425
|
+
assert.equal((session as any)._lastAssistantMessage, undefined);
|
|
426
|
+
});
|
|
427
|
+
|
|
109
428
|
it("switchSession() invokes abort() before _disconnectFromAgent()", async () => {
|
|
110
429
|
const session = await createSession({ persistSessions: true });
|
|
111
430
|
// Seed a session file to switch to (switchSession reads from the session manager).
|
|
@@ -272,6 +272,12 @@ export class AgentSession {
|
|
|
272
272
|
// Extension system
|
|
273
273
|
private _extensionRunner: ExtensionRunner | undefined = undefined;
|
|
274
274
|
private _turnIndex = 0;
|
|
275
|
+
private _processingAgentEnd = false;
|
|
276
|
+
/** True while newSession()/switchSession() is in progress; signals agent_end
|
|
277
|
+
* post-handlers to bail rather than corrupt new-session state. */
|
|
278
|
+
private _sessionSwitchPending = false;
|
|
279
|
+
private _processingQueuedAgentEnd = false;
|
|
280
|
+
private _sessionTransitionStartedDuringAgentEnd = false;
|
|
275
281
|
|
|
276
282
|
private _resourceLoader: ResourceLoader;
|
|
277
283
|
private _customTools: ToolDefinition[];
|
|
@@ -433,7 +439,23 @@ export class AgentSession {
|
|
|
433
439
|
}
|
|
434
440
|
|
|
435
441
|
// Emit to extensions first
|
|
436
|
-
|
|
442
|
+
let skipAgentEndPostHandlers = false;
|
|
443
|
+
if (event.type === "agent_end") {
|
|
444
|
+
this._processingQueuedAgentEnd = true;
|
|
445
|
+
try {
|
|
446
|
+
await this._emitExtensionEvent(event);
|
|
447
|
+
} finally {
|
|
448
|
+
this._processingQueuedAgentEnd = false;
|
|
449
|
+
skipAgentEndPostHandlers = this._sessionTransitionStartedDuringAgentEnd;
|
|
450
|
+
this._sessionTransitionStartedDuringAgentEnd = false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (skipAgentEndPostHandlers) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
await this._emitExtensionEvent(event);
|
|
458
|
+
}
|
|
437
459
|
|
|
438
460
|
// Notify all listeners
|
|
439
461
|
this._emit(event);
|
|
@@ -491,6 +513,13 @@ export class AgentSession {
|
|
|
491
513
|
|
|
492
514
|
// Check auto-retry and auto-compaction after agent completes
|
|
493
515
|
if (event.type === "agent_end" && this._lastAssistantMessage) {
|
|
516
|
+
// A session transition started during agent_end handler execution -
|
|
517
|
+
// bail to avoid running retry/compaction against new-session state.
|
|
518
|
+
if (this._sessionSwitchPending) {
|
|
519
|
+
this._lastAssistantMessage = undefined;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
494
523
|
const msg = this._lastAssistantMessage;
|
|
495
524
|
this._lastAssistantMessage = undefined;
|
|
496
525
|
|
|
@@ -621,33 +650,39 @@ export class AgentSession {
|
|
|
621
650
|
|
|
622
651
|
/** Emit extension events based on agent events */
|
|
623
652
|
private async _emitExtensionEvent(event: AgentEvent): Promise<void> {
|
|
624
|
-
|
|
653
|
+
const extensionRunner = this._extensionRunner;
|
|
654
|
+
if (!extensionRunner) return;
|
|
625
655
|
|
|
626
656
|
if (event.type === "agent_start") {
|
|
627
657
|
this._turnIndex = 0;
|
|
628
|
-
await
|
|
658
|
+
await extensionRunner.emit({ type: "agent_start" });
|
|
629
659
|
} else if (event.type === "agent_end") {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
last
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
? "
|
|
641
|
-
: "
|
|
642
|
-
|
|
643
|
-
|
|
660
|
+
this._processingAgentEnd = true;
|
|
661
|
+
try {
|
|
662
|
+
await extensionRunner.emit({ type: "agent_end", messages: event.messages });
|
|
663
|
+
// `stop` fires on true quiescence: the agent cleanly completed and is now
|
|
664
|
+
// waiting for the user. Use the last assistant message's stopReason to
|
|
665
|
+
// distinguish clean completion from error/cancellation.
|
|
666
|
+
const last = event.messages[event.messages.length - 1];
|
|
667
|
+
const stopReason: "completed" | "cancelled" | "error" | "blocked" =
|
|
668
|
+
last?.role === "assistant"
|
|
669
|
+
? last.stopReason === "aborted"
|
|
670
|
+
? "cancelled"
|
|
671
|
+
: last.stopReason === "error"
|
|
672
|
+
? "error"
|
|
673
|
+
: "completed"
|
|
674
|
+
: "completed";
|
|
675
|
+
await extensionRunner.emitStop({ reason: stopReason, lastMessage: last });
|
|
676
|
+
} finally {
|
|
677
|
+
this._processingAgentEnd = false;
|
|
678
|
+
}
|
|
644
679
|
} else if (event.type === "turn_start") {
|
|
645
680
|
const extensionEvent: TurnStartEvent = {
|
|
646
681
|
type: "turn_start",
|
|
647
682
|
turnIndex: this._turnIndex,
|
|
648
683
|
timestamp: Date.now(),
|
|
649
684
|
};
|
|
650
|
-
await
|
|
685
|
+
await extensionRunner.emit(extensionEvent);
|
|
651
686
|
} else if (event.type === "turn_end") {
|
|
652
687
|
const extensionEvent: TurnEndEvent = {
|
|
653
688
|
type: "turn_end",
|
|
@@ -655,27 +690,27 @@ export class AgentSession {
|
|
|
655
690
|
message: event.message,
|
|
656
691
|
toolResults: event.toolResults,
|
|
657
692
|
};
|
|
658
|
-
await
|
|
693
|
+
await extensionRunner.emit(extensionEvent);
|
|
659
694
|
this._turnIndex++;
|
|
660
695
|
} else if (event.type === "message_start") {
|
|
661
696
|
const extensionEvent: MessageStartEvent = {
|
|
662
697
|
type: "message_start",
|
|
663
698
|
message: event.message,
|
|
664
699
|
};
|
|
665
|
-
await
|
|
700
|
+
await extensionRunner.emit(extensionEvent);
|
|
666
701
|
} else if (event.type === "message_update") {
|
|
667
702
|
const extensionEvent: MessageUpdateEvent = {
|
|
668
703
|
type: "message_update",
|
|
669
704
|
message: event.message,
|
|
670
705
|
assistantMessageEvent: event.assistantMessageEvent,
|
|
671
706
|
};
|
|
672
|
-
await
|
|
707
|
+
await extensionRunner.emit(extensionEvent);
|
|
673
708
|
} else if (event.type === "message_end") {
|
|
674
709
|
const extensionEvent: MessageEndEvent = {
|
|
675
710
|
type: "message_end",
|
|
676
711
|
message: event.message,
|
|
677
712
|
};
|
|
678
|
-
await
|
|
713
|
+
await extensionRunner.emit(extensionEvent);
|
|
679
714
|
} else if (event.type === "tool_execution_start") {
|
|
680
715
|
const extensionEvent: ToolExecutionStartEvent = {
|
|
681
716
|
type: "tool_execution_start",
|
|
@@ -683,7 +718,7 @@ export class AgentSession {
|
|
|
683
718
|
toolName: event.toolName,
|
|
684
719
|
args: event.args,
|
|
685
720
|
};
|
|
686
|
-
await
|
|
721
|
+
await extensionRunner.emit(extensionEvent);
|
|
687
722
|
} else if (event.type === "tool_execution_update") {
|
|
688
723
|
const extensionEvent: ToolExecutionUpdateEvent = {
|
|
689
724
|
type: "tool_execution_update",
|
|
@@ -692,7 +727,7 @@ export class AgentSession {
|
|
|
692
727
|
args: event.args,
|
|
693
728
|
partialResult: event.partialResult,
|
|
694
729
|
};
|
|
695
|
-
await
|
|
730
|
+
await extensionRunner.emit(extensionEvent);
|
|
696
731
|
} else if (event.type === "tool_execution_end") {
|
|
697
732
|
const extensionEvent: ToolExecutionEndEvent = {
|
|
698
733
|
type: "tool_execution_end",
|
|
@@ -701,7 +736,7 @@ export class AgentSession {
|
|
|
701
736
|
result: event.result,
|
|
702
737
|
isError: event.isError,
|
|
703
738
|
};
|
|
704
|
-
await
|
|
739
|
+
await extensionRunner.emit(extensionEvent);
|
|
705
740
|
}
|
|
706
741
|
}
|
|
707
742
|
|
|
@@ -1546,24 +1581,54 @@ export class AgentSession {
|
|
|
1546
1581
|
// between tool execution and response processing. Also fire Stop so
|
|
1547
1582
|
// Layer 0 hooks see a consistent view of session quiescence.
|
|
1548
1583
|
if (!this.isStreaming && this._extensionRunner) {
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
messages
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1584
|
+
const wasProcessingAgentEnd = this._processingAgentEnd;
|
|
1585
|
+
this._processingAgentEnd = true;
|
|
1586
|
+
try {
|
|
1587
|
+
const messages = this.agent.state.messages;
|
|
1588
|
+
await this._extensionRunner.emit({
|
|
1589
|
+
type: "agent_end",
|
|
1590
|
+
messages,
|
|
1591
|
+
});
|
|
1592
|
+
const last = messages[messages.length - 1];
|
|
1593
|
+
const stopReason: "completed" | "cancelled" | "error" | "blocked" =
|
|
1594
|
+
last?.role === "assistant"
|
|
1595
|
+
? last.stopReason === "aborted"
|
|
1596
|
+
? "cancelled"
|
|
1597
|
+
: last.stopReason === "error"
|
|
1598
|
+
? "error"
|
|
1599
|
+
: "completed"
|
|
1600
|
+
: "cancelled";
|
|
1601
|
+
await this._extensionRunner.emitStop({ reason: stopReason, lastMessage: last });
|
|
1602
|
+
} finally {
|
|
1603
|
+
this._processingAgentEnd = wasProcessingAgentEnd;
|
|
1604
|
+
}
|
|
1564
1605
|
}
|
|
1565
1606
|
}
|
|
1566
1607
|
|
|
1608
|
+
private async _settleCurrentTurnForSessionTransition(): Promise<void> {
|
|
1609
|
+
if (this._processingAgentEnd) {
|
|
1610
|
+
// Wait for the agent to fully settle. When called from inside an
|
|
1611
|
+
// agent_end extension handler, the agent may already be idle - but
|
|
1612
|
+
// _processAgentEvent still has retry/compaction tail work to run after
|
|
1613
|
+
// _emitExtensionEvent returns. waitForIdle() is effectively a no-op when
|
|
1614
|
+
// already idle, so awaiting it unconditionally is safe and ensures we
|
|
1615
|
+
// don't proceed into the session reset while that tail is still on the stack.
|
|
1616
|
+
await this.agent.waitForIdle();
|
|
1617
|
+
|
|
1618
|
+
if (this._processingQueuedAgentEnd) {
|
|
1619
|
+
this._sessionTransitionStartedDuringAgentEnd = true;
|
|
1620
|
+
this._lastAssistantMessage = undefined;
|
|
1621
|
+
}
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// #4243: Normal session transitions must abort before disconnecting so
|
|
1626
|
+
// message_end/agent_end events fire while listeners are still connected.
|
|
1627
|
+
// During agent_end handling the turn is already ending; aborting there can
|
|
1628
|
+
// convert a successful auto-mode handoff into an aborted provider message.
|
|
1629
|
+
await this.abort();
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1567
1632
|
/**
|
|
1568
1633
|
* Start a new session, optionally with initial messages and parent tracking.
|
|
1569
1634
|
* Clears all messages and starts a new session.
|
|
@@ -1592,21 +1657,23 @@ export class AgentSession {
|
|
|
1592
1657
|
}
|
|
1593
1658
|
}
|
|
1594
1659
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1660
|
+
this._sessionSwitchPending = true;
|
|
1661
|
+
try {
|
|
1662
|
+
await this._settleCurrentTurnForSessionTransition();
|
|
1663
|
+
|
|
1664
|
+
// #3731: If the caller aborted (e.g. runUnit() timed out and restored cwd to
|
|
1665
|
+
// project root), discard this session before capturing process.cwd() and
|
|
1666
|
+
// rebuilding the tool runtime. Without this check, the late newSession()
|
|
1667
|
+
// would rebuild tools with root cwd, breaking worktree isolation.
|
|
1668
|
+
if (options?.abortSignal?.aborted) {
|
|
1669
|
+
return false;
|
|
1670
|
+
}
|
|
1599
1671
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
if (options?.abortSignal?.aborted) {
|
|
1605
|
-
return false;
|
|
1672
|
+
this._disconnectFromAgent();
|
|
1673
|
+
this.agent.reset();
|
|
1674
|
+
} finally {
|
|
1675
|
+
this._sessionSwitchPending = false;
|
|
1606
1676
|
}
|
|
1607
|
-
|
|
1608
|
-
this._disconnectFromAgent();
|
|
1609
|
-
this.agent.reset();
|
|
1610
1677
|
// Update cwd to current process directory — auto-mode may have chdir'd
|
|
1611
1678
|
// into a worktree since the original session was created.
|
|
1612
1679
|
const previousCwd = this._cwd;
|
|
@@ -2457,12 +2524,14 @@ export class AgentSession {
|
|
|
2457
2524
|
}
|
|
2458
2525
|
}
|
|
2459
2526
|
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2527
|
+
this._sessionSwitchPending = true;
|
|
2528
|
+
try {
|
|
2529
|
+
await this._settleCurrentTurnForSessionTransition();
|
|
2530
|
+
this._disconnectFromAgent();
|
|
2531
|
+
} finally {
|
|
2532
|
+
this._sessionSwitchPending = false;
|
|
2533
|
+
}
|
|
2534
|
+
this._steeringMessages = [];
|
|
2466
2535
|
this._followUpMessages = [];
|
|
2467
2536
|
this._pendingNextTurnMessages = [];
|
|
2468
2537
|
|