github-issue-tower-defence-management 1.122.11 → 1.122.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +12 -50
- package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
- package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +32 -3
- package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +27 -35
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +42 -213
- package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +11 -83
- package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +68 -5
- package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +43 -3
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +147 -127
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +31 -52
- package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +0 -8
- package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +0 -10
- package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
- package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +0 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +0 -8
- package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
|
@@ -3,7 +3,6 @@ import * as os from 'os';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_STATE_RETENTION_WINDOW_SECONDS,
|
|
6
|
-
ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS,
|
|
7
6
|
FileSystemSilentSessionCandidateStateRepository,
|
|
8
7
|
} from './FileSystemSilentSessionCandidateStateRepository';
|
|
9
8
|
|
|
@@ -142,7 +141,6 @@ describe('FileSystemSilentSessionCandidateStateRepository', () => {
|
|
|
142
141
|
recordedEpochSeconds: Math.floor(secondSaveAt.getTime() / 1000),
|
|
143
142
|
},
|
|
144
143
|
],
|
|
145
|
-
announcedRunningSubAgents: [],
|
|
146
144
|
});
|
|
147
145
|
});
|
|
148
146
|
|
|
@@ -201,224 +199,55 @@ describe('FileSystemSilentSessionCandidateStateRepository', () => {
|
|
|
201
199
|
expect(DEFAULT_STATE_RETENTION_WINDOW_SECONDS).toBe(60 * 60);
|
|
202
200
|
});
|
|
203
201
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
|
|
228
|
-
sessionName: sessionAlpha,
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
expect(loaded).toEqual(new Set<string>());
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('replaces the previous announced labels of the same session', async () => {
|
|
235
|
-
const repository = new FileSystemSilentSessionCandidateStateRepository(
|
|
236
|
-
stateFilePath,
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
240
|
-
sessionName: sessionAlpha,
|
|
241
|
-
labels: ['sub-process-1'],
|
|
242
|
-
now: new Date('2026-06-26T00:00:00Z'),
|
|
243
|
-
});
|
|
244
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
245
|
-
sessionName: sessionAlpha,
|
|
246
|
-
labels: ['sub-process-2'],
|
|
247
|
-
now: new Date('2026-06-26T00:01:00Z'),
|
|
248
|
-
});
|
|
249
|
-
const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
|
|
250
|
-
sessionName: sessionAlpha,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
expect(loaded).toEqual(new Set(['sub-process-2']));
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('removes the stored entry when saving an empty label set', async () => {
|
|
257
|
-
const repository = new FileSystemSilentSessionCandidateStateRepository(
|
|
258
|
-
stateFilePath,
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
262
|
-
sessionName: sessionAlpha,
|
|
263
|
-
labels: ['sub-process-1'],
|
|
264
|
-
now: new Date('2026-06-26T00:00:00Z'),
|
|
265
|
-
});
|
|
266
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
267
|
-
sessionName: sessionAlpha,
|
|
268
|
-
labels: [],
|
|
269
|
-
now: new Date('2026-06-26T00:01:00Z'),
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
expect(
|
|
273
|
-
await repository.loadAnnouncedRunningSubAgentLabels({
|
|
274
|
-
sessionName: sessionAlpha,
|
|
275
|
-
}),
|
|
276
|
-
).toEqual(new Set<string>());
|
|
277
|
-
const persisted: unknown = JSON.parse(
|
|
278
|
-
fs.readFileSync(stateFilePath, 'utf8'),
|
|
279
|
-
);
|
|
280
|
-
expect(persisted).toEqual({
|
|
281
|
-
candidates: [],
|
|
282
|
-
announcedRunningSubAgents: [],
|
|
283
|
-
});
|
|
284
|
-
});
|
|
202
|
+
it('reads a legacy state file containing the removed announced-label key and drops the key on the next save', async () => {
|
|
203
|
+
const at = new Date('2026-06-26T00:00:00Z');
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
stateFilePath,
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
candidates: [
|
|
208
|
+
{
|
|
209
|
+
sessionName: sessionAlpha,
|
|
210
|
+
recordedEpochSeconds: Math.floor(at.getTime() / 1000),
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
announcedRunningSubAgents: [
|
|
214
|
+
{
|
|
215
|
+
sessionName: sessionAlpha,
|
|
216
|
+
labels: ['sub-process-1'],
|
|
217
|
+
recordedEpochSeconds: Math.floor(at.getTime() / 1000),
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const repository = new FileSystemSilentSessionCandidateStateRepository(
|
|
223
|
+
stateFilePath,
|
|
224
|
+
);
|
|
285
225
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
);
|
|
290
|
-
const at = new Date('2026-06-26T00:00:00Z');
|
|
291
|
-
|
|
292
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
293
|
-
sessionName: sessionAlpha,
|
|
294
|
-
labels: ['sub-process-1'],
|
|
295
|
-
now: at,
|
|
296
|
-
});
|
|
297
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
298
|
-
sessionName: sessionBravo,
|
|
299
|
-
labels: ['sub-process-2'],
|
|
300
|
-
now: at,
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
expect(
|
|
304
|
-
await repository.loadAnnouncedRunningSubAgentLabels({
|
|
305
|
-
sessionName: sessionAlpha,
|
|
306
|
-
}),
|
|
307
|
-
).toEqual(new Set(['sub-process-1']));
|
|
308
|
-
expect(
|
|
309
|
-
await repository.loadAnnouncedRunningSubAgentLabels({
|
|
310
|
-
sessionName: sessionBravo,
|
|
311
|
-
}),
|
|
312
|
-
).toEqual(new Set(['sub-process-2']));
|
|
226
|
+
const loaded = await repository.loadRecentCandidateSessionNames({
|
|
227
|
+
now: new Date('2026-06-26T00:01:00Z'),
|
|
228
|
+
recencyWindowSeconds: 15 * 60,
|
|
313
229
|
});
|
|
230
|
+
expect(loaded).toEqual(new Set([sessionAlpha]));
|
|
314
231
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const firstSaveAt = new Date('2026-06-26T00:00:00Z');
|
|
320
|
-
const secondSaveAt = new Date(
|
|
321
|
-
firstSaveAt.getTime() +
|
|
322
|
-
(ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS + 1) * 1000,
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
326
|
-
sessionName: sessionAlpha,
|
|
327
|
-
labels: ['sub-process-1'],
|
|
328
|
-
now: firstSaveAt,
|
|
329
|
-
});
|
|
330
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
331
|
-
sessionName: sessionBravo,
|
|
332
|
-
labels: ['sub-process-2'],
|
|
333
|
-
now: secondSaveAt,
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
expect(
|
|
337
|
-
await repository.loadAnnouncedRunningSubAgentLabels({
|
|
338
|
-
sessionName: sessionAlpha,
|
|
339
|
-
}),
|
|
340
|
-
).toEqual(new Set<string>());
|
|
341
|
-
expect(
|
|
342
|
-
await repository.loadAnnouncedRunningSubAgentLabels({
|
|
343
|
-
sessionName: sessionBravo,
|
|
344
|
-
}),
|
|
345
|
-
).toEqual(new Set(['sub-process-2']));
|
|
232
|
+
const saveAt = new Date('2026-06-26T00:02:00Z');
|
|
233
|
+
await repository.saveCandidateSessionNames({
|
|
234
|
+
sessionNames: [sessionBravo],
|
|
235
|
+
now: saveAt,
|
|
346
236
|
});
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const at = new Date('2026-06-26T00:00:00Z');
|
|
353
|
-
|
|
354
|
-
await repository.saveCandidateSessionNames({
|
|
355
|
-
sessionNames: [sessionAlpha],
|
|
356
|
-
now: at,
|
|
357
|
-
});
|
|
358
|
-
await repository.saveAnnouncedRunningSubAgentLabels({
|
|
359
|
-
sessionName: sessionBravo,
|
|
360
|
-
labels: ['sub-process-1'],
|
|
361
|
-
now: at,
|
|
362
|
-
});
|
|
363
|
-
await repository.saveCandidateSessionNames({
|
|
364
|
-
sessionNames: [sessionAlpha],
|
|
365
|
-
now: new Date('2026-06-26T00:01:00Z'),
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
const loadedCandidates = await repository.loadRecentCandidateSessionNames(
|
|
237
|
+
const persisted: unknown = JSON.parse(
|
|
238
|
+
fs.readFileSync(stateFilePath, 'utf8'),
|
|
239
|
+
);
|
|
240
|
+
expect(persisted).toEqual({
|
|
241
|
+
candidates: [
|
|
369
242
|
{
|
|
370
|
-
|
|
371
|
-
|
|
243
|
+
sessionName: sessionAlpha,
|
|
244
|
+
recordedEpochSeconds: Math.floor(at.getTime() / 1000),
|
|
372
245
|
},
|
|
373
|
-
|
|
374
|
-
expect(loadedCandidates).toEqual(new Set([sessionAlpha]));
|
|
375
|
-
expect(
|
|
376
|
-
await repository.loadAnnouncedRunningSubAgentLabels({
|
|
246
|
+
{
|
|
377
247
|
sessionName: sessionBravo,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
it('treats a corrupt state file as no recorded announcements', async () => {
|
|
383
|
-
fs.writeFileSync(stateFilePath, 'not valid json');
|
|
384
|
-
const repository = new FileSystemSilentSessionCandidateStateRepository(
|
|
385
|
-
stateFilePath,
|
|
386
|
-
);
|
|
387
|
-
|
|
388
|
-
const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
|
|
389
|
-
sessionName: sessionAlpha,
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
expect(loaded).toEqual(new Set<string>());
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('ignores a stored announced entry whose labels are malformed', async () => {
|
|
396
|
-
fs.writeFileSync(
|
|
397
|
-
stateFilePath,
|
|
398
|
-
JSON.stringify({
|
|
399
|
-
candidates: [],
|
|
400
|
-
announcedRunningSubAgents: [
|
|
401
|
-
{
|
|
402
|
-
sessionName: sessionAlpha,
|
|
403
|
-
labels: ['sub-process-1', 42],
|
|
404
|
-
recordedEpochSeconds: 1782000000,
|
|
405
|
-
},
|
|
406
|
-
],
|
|
407
|
-
}),
|
|
408
|
-
);
|
|
409
|
-
const repository = new FileSystemSilentSessionCandidateStateRepository(
|
|
410
|
-
stateFilePath,
|
|
411
|
-
);
|
|
412
|
-
|
|
413
|
-
const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
|
|
414
|
-
sessionName: sessionAlpha,
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
expect(loaded).toEqual(new Set<string>());
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it('exposes the announced-label retention window as a named constant', () => {
|
|
421
|
-
expect(ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS).toBe(24 * 60 * 60);
|
|
248
|
+
recordedEpochSeconds: Math.floor(saveAt.getTime() / 1000),
|
|
249
|
+
},
|
|
250
|
+
],
|
|
422
251
|
});
|
|
423
252
|
});
|
|
424
253
|
});
|
|
@@ -8,23 +8,24 @@ type StoredCandidateEntry = {
|
|
|
8
8
|
recordedEpochSeconds: number;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
type StoredAnnouncedRunningEntry = {
|
|
12
|
-
sessionName: string;
|
|
13
|
-
labels: string[];
|
|
14
|
-
recordedEpochSeconds: number;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
11
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
18
12
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
13
|
|
|
20
14
|
export const DEFAULT_STATE_RETENTION_WINDOW_SECONDS = 60 * 60;
|
|
21
|
-
export const ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS = 24 * 60 * 60;
|
|
22
15
|
|
|
23
16
|
const defaultStateFilePath = (): string => {
|
|
24
17
|
const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
|
|
25
18
|
return path.join(base, 'tdpm', 'silent-session-candidates.json');
|
|
26
19
|
};
|
|
27
20
|
|
|
21
|
+
// Persists only the candidate set used by the two-consecutive-cycle
|
|
22
|
+
// debounce. The formerly persisted announced-running-sub-agent labels
|
|
23
|
+
// (fire-once state for the long-running advisory) were removed: the
|
|
24
|
+
// long-running advisory now re-fires on every qualifying cycle, so no
|
|
25
|
+
// per-label announcement state exists anymore. An old state file that
|
|
26
|
+
// still contains an `announcedRunningSubAgents` key is read without
|
|
27
|
+
// error (unknown keys are ignored) and the key is dropped on the next
|
|
28
|
+
// write.
|
|
28
29
|
export class FileSystemSilentSessionCandidateStateRepository implements SilentSessionCandidateStateRepository {
|
|
29
30
|
constructor(
|
|
30
31
|
private readonly stateFilePath: string = defaultStateFilePath(),
|
|
@@ -71,42 +72,7 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
|
|
|
71
72
|
recordedEpochSeconds,
|
|
72
73
|
});
|
|
73
74
|
}
|
|
74
|
-
this.writeState(
|
|
75
|
-
Array.from(mergedBySessionName.values()),
|
|
76
|
-
this.readAnnouncedRunningEntries(),
|
|
77
|
-
);
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
loadAnnouncedRunningSubAgentLabels = async (params: {
|
|
81
|
-
sessionName: string;
|
|
82
|
-
}): Promise<Set<string>> => {
|
|
83
|
-
const entry = this.readAnnouncedRunningEntries().find(
|
|
84
|
-
(candidate) => candidate.sessionName === params.sessionName,
|
|
85
|
-
);
|
|
86
|
-
return new Set(entry?.labels ?? []);
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
saveAnnouncedRunningSubAgentLabels = async (params: {
|
|
90
|
-
sessionName: string;
|
|
91
|
-
labels: string[];
|
|
92
|
-
now: Date;
|
|
93
|
-
}): Promise<void> => {
|
|
94
|
-
const recordedEpochSeconds = Math.floor(params.now.getTime() / 1000);
|
|
95
|
-
const oldestRetainedEpochSeconds =
|
|
96
|
-
recordedEpochSeconds - ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS;
|
|
97
|
-
const retainedEntries = this.readAnnouncedRunningEntries().filter(
|
|
98
|
-
(entry) =>
|
|
99
|
-
entry.sessionName !== params.sessionName &&
|
|
100
|
-
entry.recordedEpochSeconds >= oldestRetainedEpochSeconds,
|
|
101
|
-
);
|
|
102
|
-
if (params.labels.length > 0) {
|
|
103
|
-
retainedEntries.push({
|
|
104
|
-
sessionName: params.sessionName,
|
|
105
|
-
labels: params.labels,
|
|
106
|
-
recordedEpochSeconds,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
this.writeState(this.readCandidateEntries(), retainedEntries);
|
|
75
|
+
this.writeState(Array.from(mergedBySessionName.values()));
|
|
110
76
|
};
|
|
111
77
|
|
|
112
78
|
private readState = (): Record<string, unknown> => {
|
|
@@ -151,49 +117,11 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
|
|
|
151
117
|
return entries;
|
|
152
118
|
};
|
|
153
119
|
|
|
154
|
-
private
|
|
155
|
-
const storedEntries = this.readState().announcedRunningSubAgents;
|
|
156
|
-
if (!Array.isArray(storedEntries)) {
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
const entries: StoredAnnouncedRunningEntry[] = [];
|
|
160
|
-
for (const storedEntry of storedEntries) {
|
|
161
|
-
if (!isRecord(storedEntry)) {
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
const sessionName = storedEntry.sessionName;
|
|
165
|
-
const recordedEpochSeconds = storedEntry.recordedEpochSeconds;
|
|
166
|
-
const storedLabels = storedEntry.labels;
|
|
167
|
-
if (
|
|
168
|
-
typeof sessionName !== 'string' ||
|
|
169
|
-
typeof recordedEpochSeconds !== 'number' ||
|
|
170
|
-
!Number.isFinite(recordedEpochSeconds) ||
|
|
171
|
-
!Array.isArray(storedLabels)
|
|
172
|
-
) {
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
const labels = storedLabels.filter(
|
|
176
|
-
(label): label is string => typeof label === 'string',
|
|
177
|
-
);
|
|
178
|
-
if (labels.length !== storedLabels.length) {
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
entries.push({ sessionName, labels, recordedEpochSeconds });
|
|
182
|
-
}
|
|
183
|
-
return entries;
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
private writeState = (
|
|
187
|
-
candidates: StoredCandidateEntry[],
|
|
188
|
-
announcedRunningSubAgents: StoredAnnouncedRunningEntry[],
|
|
189
|
-
): void => {
|
|
120
|
+
private writeState = (candidates: StoredCandidateEntry[]): void => {
|
|
190
121
|
const directory = path.dirname(this.stateFilePath);
|
|
191
122
|
fs.mkdirSync(directory, { recursive: true });
|
|
192
123
|
const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
|
|
193
|
-
fs.writeFileSync(
|
|
194
|
-
temporaryPath,
|
|
195
|
-
JSON.stringify({ candidates, announcedRunningSubAgents }),
|
|
196
|
-
);
|
|
124
|
+
fs.writeFileSync(temporaryPath, JSON.stringify({ candidates }));
|
|
197
125
|
fs.renameSync(temporaryPath, this.stateFilePath);
|
|
198
126
|
};
|
|
199
127
|
}
|
|
@@ -164,7 +164,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
|
|
|
164
164
|
},
|
|
165
165
|
];
|
|
166
166
|
|
|
167
|
-
const
|
|
167
|
+
const inFlightToolResultTailEntries = (startTimestamp: string): object[] => [
|
|
168
168
|
{ type: 'user', timestamp: startTimestamp, message: { role: 'user' } },
|
|
169
169
|
{
|
|
170
170
|
type: 'assistant',
|
|
@@ -370,13 +370,17 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
|
|
|
370
370
|
expect(result.size).toBe(0);
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
-
it('
|
|
373
|
+
it('keeps an in-flight sub-agent whose transcript tail is an ordinary tool result awaiting the next assistant turn', async () => {
|
|
374
|
+
// The transcript tail of an actively working agent alternates between a
|
|
375
|
+
// pending tool_use (assistant) and a tool_result (user). Treating the
|
|
376
|
+
// tool_result tail as completion made an active agent flap in and out of
|
|
377
|
+
// the snapshot between samples; it must stay in the snapshot instead.
|
|
374
378
|
const sessionName = 'https_//github_com/owner/repo/issues/9';
|
|
375
379
|
writeAgentTranscript(
|
|
376
380
|
sessionName,
|
|
377
381
|
'toolresultend',
|
|
378
|
-
|
|
379
|
-
nowEpochSeconds -
|
|
382
|
+
inFlightToolResultTailEntries('2026-06-27T11:50:00.000Z'),
|
|
383
|
+
nowEpochSeconds - 60,
|
|
380
384
|
);
|
|
381
385
|
const repository = new TranscriptSessionSubAgentActivityRepository(
|
|
382
386
|
createResolver(),
|
|
@@ -389,7 +393,14 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
|
|
|
389
393
|
transcriptMapFor([sessionName]),
|
|
390
394
|
);
|
|
391
395
|
|
|
392
|
-
expect(result.
|
|
396
|
+
expect(result.get(sessionName)).toEqual([
|
|
397
|
+
{
|
|
398
|
+
label: 'agent-toolresultend',
|
|
399
|
+
silentSeconds: 60,
|
|
400
|
+
runningSeconds: 600,
|
|
401
|
+
waitingOnExternalProcess: false,
|
|
402
|
+
},
|
|
403
|
+
]);
|
|
393
404
|
});
|
|
394
405
|
|
|
395
406
|
it('excludes a finished sub-agent whose transcript ends with the final StructuredOutput tool result', async () => {
|
|
@@ -414,6 +425,58 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
|
|
|
414
425
|
expect(result.size).toBe(0);
|
|
415
426
|
});
|
|
416
427
|
|
|
428
|
+
it('keeps an in-flight sub-agent whose tail tool result follows a Bash call even when an earlier turn used StructuredOutput', async () => {
|
|
429
|
+
// Only the assistant turn immediately preceding the tail tool_result
|
|
430
|
+
// decides completion: an earlier StructuredOutput call that was already
|
|
431
|
+
// consumed must not mark a later in-flight tool_result as terminal.
|
|
432
|
+
const sessionName = 'https_//github_com/owner/repo/issues/9';
|
|
433
|
+
const startTimestamp = '2026-06-27T11:50:00.000Z';
|
|
434
|
+
writeAgentTranscript(
|
|
435
|
+
sessionName,
|
|
436
|
+
'laterbash',
|
|
437
|
+
[
|
|
438
|
+
...structuredOutputTerminalEntries(startTimestamp),
|
|
439
|
+
{
|
|
440
|
+
type: 'assistant',
|
|
441
|
+
timestamp: startTimestamp,
|
|
442
|
+
message: {
|
|
443
|
+
role: 'assistant',
|
|
444
|
+
stop_reason: 'tool_use',
|
|
445
|
+
content: [{ type: 'tool_use', name: 'Bash', input: {} }],
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
type: 'user',
|
|
450
|
+
timestamp: startTimestamp,
|
|
451
|
+
message: {
|
|
452
|
+
role: 'user',
|
|
453
|
+
content: [{ type: 'tool_result', content: 'command output' }],
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
nowEpochSeconds - 60,
|
|
458
|
+
);
|
|
459
|
+
const repository = new TranscriptSessionSubAgentActivityRepository(
|
|
460
|
+
createResolver(),
|
|
461
|
+
emptyProcessLister(),
|
|
462
|
+
now,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const result = await repository.listSubAgentActivitiesBySessionName(
|
|
466
|
+
[sessionName],
|
|
467
|
+
transcriptMapFor([sessionName]),
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
expect(result.get(sessionName)).toEqual([
|
|
471
|
+
{
|
|
472
|
+
label: 'agent-laterbash',
|
|
473
|
+
silentSeconds: 60,
|
|
474
|
+
runningSeconds: 600,
|
|
475
|
+
waitingOnExternalProcess: false,
|
|
476
|
+
},
|
|
477
|
+
]);
|
|
478
|
+
});
|
|
479
|
+
|
|
417
480
|
it('excludes a dead sub-agent whose transcript ends with a user entry that has no content blocks', async () => {
|
|
418
481
|
const sessionName = 'https_//github_com/owner/repo/issues/9';
|
|
419
482
|
writeAgentTranscript(
|
|
@@ -48,7 +48,21 @@ const readContentBlocks = (
|
|
|
48
48
|
return content.filter(isRecord);
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Tool whose result marks the sub-agent's final structured answer: once its
|
|
52
|
+
// tool_result is recorded, the sub-agent has delivered its output and is
|
|
53
|
+
// genuinely complete even though the transcript tail is a user entry.
|
|
54
|
+
const COMPLETION_TOOL_NAMES = new Set(['StructuredOutput']);
|
|
55
|
+
|
|
56
|
+
const entryToolUseNames = (message: Record<string, unknown>): string[] =>
|
|
57
|
+
readContentBlocks(message)
|
|
58
|
+
.filter((block) => readString(block, 'type') === 'tool_use')
|
|
59
|
+
.map((block) => readString(block, 'name'))
|
|
60
|
+
.filter((name): name is string => name !== null);
|
|
61
|
+
|
|
62
|
+
const entryIndicatesCompletion = (
|
|
63
|
+
entry: Record<string, unknown>,
|
|
64
|
+
precedingAssistantToolUseNames: string[],
|
|
65
|
+
): boolean => {
|
|
52
66
|
const type = readString(entry, 'type');
|
|
53
67
|
const message = entry.message;
|
|
54
68
|
if (!isRecord(message)) {
|
|
@@ -70,7 +84,26 @@ const entryIndicatesCompletion = (entry: Record<string, unknown>): boolean => {
|
|
|
70
84
|
}
|
|
71
85
|
const lastBlock = blocks[blocks.length - 1];
|
|
72
86
|
const lastBlockType = readString(lastBlock, 'type');
|
|
73
|
-
|
|
87
|
+
if (lastBlockType === 'text') {
|
|
88
|
+
// A trailing user text entry is an interruption (e.g. "[Request
|
|
89
|
+
// interrupted by user]"): the sub-agent will not produce further
|
|
90
|
+
// output, so the transcript is terminal.
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (lastBlockType === 'tool_result') {
|
|
94
|
+
// A trailing tool_result is an IN-FLIGHT state, not completion: the
|
|
95
|
+
// sub-agent has just received a tool result and the next assistant
|
|
96
|
+
// turn is being generated. Treating it as completion made an active
|
|
97
|
+
// agent flap in and out of the activity snapshot between samples
|
|
98
|
+
// (tail alternates between pending tool_use and tool_result). Only
|
|
99
|
+
// the result of an explicit completion tool (see
|
|
100
|
+
// COMPLETION_TOOL_NAMES) is a genuine terminal state, so completed
|
|
101
|
+
// agents still drop out of the snapshot.
|
|
102
|
+
return precedingAssistantToolUseNames.some((name) =>
|
|
103
|
+
COMPLETION_TOOL_NAMES.has(name),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
74
107
|
}
|
|
75
108
|
return false;
|
|
76
109
|
};
|
|
@@ -102,6 +135,7 @@ const parseTranscript = (content: string): ParsedTranscript => {
|
|
|
102
135
|
let firstEntryEpochSeconds: number | null = null;
|
|
103
136
|
let lastEntryIndicatesCompletion = false;
|
|
104
137
|
let pendingToolCommands: string[] = [];
|
|
138
|
+
let precedingAssistantToolUseNames: string[] = [];
|
|
105
139
|
for (const line of content.split('\n')) {
|
|
106
140
|
const trimmed = line.trim();
|
|
107
141
|
if (trimmed.length === 0) {
|
|
@@ -121,8 +155,14 @@ const parseTranscript = (content: string): ParsedTranscript => {
|
|
|
121
155
|
firstEntryEpochSeconds = epochSeconds;
|
|
122
156
|
}
|
|
123
157
|
if (isRecord(parsed.message)) {
|
|
124
|
-
lastEntryIndicatesCompletion = entryIndicatesCompletion(
|
|
158
|
+
lastEntryIndicatesCompletion = entryIndicatesCompletion(
|
|
159
|
+
parsed,
|
|
160
|
+
precedingAssistantToolUseNames,
|
|
161
|
+
);
|
|
125
162
|
pendingToolCommands = entryPendingToolCommands(parsed);
|
|
163
|
+
if (readString(parsed, 'type') === 'assistant') {
|
|
164
|
+
precedingAssistantToolUseNames = entryToolUseNames(parsed.message);
|
|
165
|
+
}
|
|
126
166
|
}
|
|
127
167
|
}
|
|
128
168
|
return {
|