gsd-pi 3.0.0-dev.2e8b124f7 → 3.0.0-dev.6c9a50fd0
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/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/loop.js +2 -3
- package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
- package/dist/resources/extensions/gsd/auto/phases.js +12 -4
- package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
- package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
- package/dist/resources/extensions/gsd/auto.js +27 -11
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
- package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
- package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
- package/dist/resources/extensions/gsd/preferences.js +4 -0
- package/dist/resources/extensions/gsd/repo-identity.js +39 -22
- package/dist/resources/extensions/gsd/session-lock.js +15 -2
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
- package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
- package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
- package/dist/resources/extensions/shared/next-action-ui.js +13 -5
- 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 +6 -6
- 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 +6 -6
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- 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/src/resources/extensions/gsd/auto/contracts.ts +2 -0
- package/src/resources/extensions/gsd/auto/loop.ts +2 -2
- package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
- package/src/resources/extensions/gsd/auto/phases.ts +14 -4
- package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
- package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
- package/src/resources/extensions/gsd/auto.ts +63 -18
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
- package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
- package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
- package/src/resources/extensions/gsd/preferences.ts +4 -0
- package/src/resources/extensions/gsd/repo-identity.ts +45 -25
- package/src/resources/extensions/gsd/session-lock.ts +15 -2
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
- package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
- package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
- package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
- package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
- package/src/resources/extensions/shared/next-action-ui.ts +11 -5
- package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
- /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_ssgManifest.js +0 -0
|
@@ -970,7 +970,11 @@ test("hasImplementationArtifacts does not backfill untagged commits before miles
|
|
|
970
970
|
});
|
|
971
971
|
|
|
972
972
|
const result = hasImplementationArtifacts(base, "M001");
|
|
973
|
-
assert.equal(
|
|
973
|
+
assert.equal(
|
|
974
|
+
result,
|
|
975
|
+
"unknown",
|
|
976
|
+
"integration self-diff should remain unknown when pre-milestone commits cannot be attributed",
|
|
977
|
+
);
|
|
974
978
|
assert.deepEqual(getMilestoneCommitAttributionShas("M001"), []);
|
|
975
979
|
} finally {
|
|
976
980
|
cleanup(base);
|
|
@@ -1007,7 +1011,11 @@ test("hasImplementationArtifacts does not backfill unrelated untagged implementa
|
|
|
1007
1011
|
execFileSync("git", ["commit", "-m", "feat: unrelated work"], { cwd: base, stdio: "ignore" });
|
|
1008
1012
|
|
|
1009
1013
|
const result = hasImplementationArtifacts(base, "M001");
|
|
1010
|
-
assert.equal(
|
|
1014
|
+
assert.equal(
|
|
1015
|
+
result,
|
|
1016
|
+
"unknown",
|
|
1017
|
+
"integration self-diff should remain unknown when unrelated untagged commits cannot be attributed",
|
|
1018
|
+
);
|
|
1011
1019
|
assert.deepEqual(getMilestoneCommitAttributionShas("M001"), []);
|
|
1012
1020
|
} finally {
|
|
1013
1021
|
cleanup(base);
|
|
@@ -1126,7 +1134,7 @@ test("hasImplementationArtifacts binds GSD-Task trailer to milestone via DB stat
|
|
|
1126
1134
|
}
|
|
1127
1135
|
});
|
|
1128
1136
|
|
|
1129
|
-
test("hasImplementationArtifacts
|
|
1137
|
+
test("hasImplementationArtifacts returns unknown when GSD-Task trailer cannot be bound to milestone ownership evidence", () => {
|
|
1130
1138
|
const base = makeGitBase();
|
|
1131
1139
|
try {
|
|
1132
1140
|
writeFileSync(join(base, ".git", "info", "exclude"), ".gsd/\n");
|
|
@@ -1159,17 +1167,11 @@ test("hasImplementationArtifacts does not claim Sxx/Tyy commit trailers across m
|
|
|
1159
1167
|
{ cwd: base, stdio: "ignore" },
|
|
1160
1168
|
);
|
|
1161
1169
|
|
|
1162
|
-
const
|
|
1163
|
-
const m002Result = hasImplementationArtifacts(base, "M002");
|
|
1164
|
-
assert.equal(
|
|
1165
|
-
m001Result,
|
|
1166
|
-
"absent",
|
|
1167
|
-
"Sxx/Tyy commit trailers owned by M002 must not be attributed to M001",
|
|
1168
|
-
);
|
|
1170
|
+
const result = hasImplementationArtifacts(base, "M001");
|
|
1169
1171
|
assert.equal(
|
|
1170
|
-
|
|
1171
|
-
"
|
|
1172
|
-
"
|
|
1172
|
+
result,
|
|
1173
|
+
"unknown",
|
|
1174
|
+
"integration self-diff should not conclude absent when S01/T01 cannot be bound to M001",
|
|
1173
1175
|
);
|
|
1174
1176
|
} finally {
|
|
1175
1177
|
cleanup(base);
|
|
@@ -1193,8 +1195,8 @@ test("hasImplementationArtifacts ignores malformed milestone IDs in commit-messa
|
|
|
1193
1195
|
const result = hasImplementationArtifacts(base, "M001(");
|
|
1194
1196
|
assert.equal(
|
|
1195
1197
|
result,
|
|
1196
|
-
"
|
|
1197
|
-
"malformed milestone IDs must not
|
|
1198
|
+
"unknown",
|
|
1199
|
+
"malformed milestone IDs must not force an absent classification when ownership cannot be proven",
|
|
1198
1200
|
);
|
|
1199
1201
|
} finally {
|
|
1200
1202
|
cleanup(base);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
heartbeatAutoWorker,
|
|
14
14
|
markWorkerCrashed,
|
|
15
15
|
markWorkerStopping,
|
|
16
|
+
markWorkerStoppingByPid,
|
|
16
17
|
getActiveAutoWorkers,
|
|
17
18
|
getAutoWorker,
|
|
18
19
|
} from "../db/auto-workers.ts";
|
|
@@ -71,6 +72,18 @@ test("markWorkerStopping flips status to stopping", (t) => {
|
|
|
71
72
|
assert.equal(row.status, "stopping");
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
test("markWorkerStoppingByPid flips matching active row to stopping", (t) => {
|
|
76
|
+
const base = makeBase();
|
|
77
|
+
t.after(() => cleanup(base));
|
|
78
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
79
|
+
|
|
80
|
+
const id = registerAutoWorker({ projectRootRealpath: base });
|
|
81
|
+
const pid = getAutoWorker(id)!.pid;
|
|
82
|
+
markWorkerStoppingByPid(base, pid);
|
|
83
|
+
const row = getAutoWorker(id)!;
|
|
84
|
+
assert.equal(row.status, "stopping");
|
|
85
|
+
});
|
|
86
|
+
|
|
74
87
|
test("markWorkerCrashed flips status to crashed", (t) => {
|
|
75
88
|
const base = makeBase();
|
|
76
89
|
t.after(() => cleanup(base));
|
|
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { invalidateAllCaches } from '../cache.ts';
|
|
9
9
|
import { parseUnitId } from "../unit-id.ts";
|
|
10
|
-
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask } from "../gsd-db.ts";
|
|
10
|
+
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, insertAssessment } from "../gsd-db.ts";
|
|
11
11
|
import { clearPathCache } from "../paths.ts";
|
|
12
12
|
import { clearParseCache } from "../files.ts";
|
|
13
13
|
|
|
@@ -503,6 +503,49 @@ describe("complete-milestone", () => {
|
|
|
503
503
|
}
|
|
504
504
|
});
|
|
505
505
|
|
|
506
|
+
test("handleCompleteMilestone refuses closeout when latest validation verdict is needs-attention (#5661)", async () => {
|
|
507
|
+
const { handleCompleteMilestone } = await import("../tools/complete-milestone.ts");
|
|
508
|
+
const base = createFixtureBase();
|
|
509
|
+
const mid = "M001";
|
|
510
|
+
const dbPath = join(base, ".gsd", "gsd.db");
|
|
511
|
+
try {
|
|
512
|
+
openDatabase(dbPath);
|
|
513
|
+
insertMilestone({ id: mid, title: "Test Milestone", status: "active" });
|
|
514
|
+
insertSlice({ id: "S01", milestoneId: mid, title: "Slice One", status: "complete" });
|
|
515
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: mid, title: "Task One", status: "complete" });
|
|
516
|
+
insertAssessment({
|
|
517
|
+
path: join(".gsd", "milestones", mid, `${mid}-VALIDATION.md`),
|
|
518
|
+
milestoneId: mid,
|
|
519
|
+
status: "needs-attention",
|
|
520
|
+
scope: "milestone-validation",
|
|
521
|
+
fullContent: "---\nverdict: needs-attention\nremediation_round: 1\n---\n\n# Validation\nNeeds attention.",
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const result = await handleCompleteMilestone({
|
|
525
|
+
milestoneId: mid,
|
|
526
|
+
title: "Test Milestone",
|
|
527
|
+
oneLiner: "Test",
|
|
528
|
+
narrative: "Test narrative",
|
|
529
|
+
successCriteriaResults: "Results",
|
|
530
|
+
definitionOfDoneResults: "Done",
|
|
531
|
+
requirementOutcomes: "Outcomes",
|
|
532
|
+
keyDecisions: [],
|
|
533
|
+
keyFiles: [],
|
|
534
|
+
lessonsLearned: [],
|
|
535
|
+
followUps: "",
|
|
536
|
+
deviations: "",
|
|
537
|
+
verificationPassed: true,
|
|
538
|
+
}, base);
|
|
539
|
+
|
|
540
|
+
assert.ok("error" in result, "non-pass validation verdict should block completion");
|
|
541
|
+
assert.match((result as { error: string }).error, /needs-attention/i);
|
|
542
|
+
assert.match((result as { error: string }).error, /Only verdict=pass permits closeout/i);
|
|
543
|
+
} finally {
|
|
544
|
+
try { closeDatabase(); } catch { /* */ }
|
|
545
|
+
cleanup(base);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
506
549
|
test("deriveState completing-milestone integration", async () => {
|
|
507
550
|
const { deriveState, isMilestoneComplete } = await import("../state.ts");
|
|
508
551
|
const { invalidateAllCaches: invalidateAllCachesDynamic } = await import("../cache.ts");
|
|
@@ -655,6 +698,13 @@ describe("complete-milestone", () => {
|
|
|
655
698
|
insertMilestone({ id: mid, title: "Empty Enrichment", status: "active" });
|
|
656
699
|
insertSlice({ id: "S01", milestoneId: mid, title: "Slice", status: "complete" });
|
|
657
700
|
insertTask({ id: "T01", sliceId: "S01", milestoneId: mid, title: "Task", status: "complete" });
|
|
701
|
+
insertAssessment({
|
|
702
|
+
path: join(".gsd", "milestones", mid, `${mid}-VALIDATION.md`),
|
|
703
|
+
milestoneId: mid,
|
|
704
|
+
status: "pass",
|
|
705
|
+
scope: "milestone-validation",
|
|
706
|
+
fullContent: "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nValidated.",
|
|
707
|
+
});
|
|
658
708
|
|
|
659
709
|
const result = await handleCompleteMilestone({
|
|
660
710
|
milestoneId: mid,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getSlice,
|
|
14
14
|
updateSliceStatus,
|
|
15
15
|
getSliceTasks,
|
|
16
|
+
setSliceSummaryMd,
|
|
16
17
|
SCHEMA_VERSION,
|
|
17
18
|
} from '../gsd-db.ts';
|
|
18
19
|
import { handleCompleteSlice } from '../tools/complete-slice.ts';
|
|
@@ -407,6 +408,60 @@ console.log('\n=== complete-slice: handler with missing roadmap ===');
|
|
|
407
408
|
cleanup(dbPath);
|
|
408
409
|
}
|
|
409
410
|
|
|
411
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
412
|
+
// complete-slice: backfills omitted requirements from rendered summary
|
|
413
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
414
|
+
|
|
415
|
+
console.log('\n=== complete-slice: backfills omitted requirements from rendered summary ===');
|
|
416
|
+
{
|
|
417
|
+
const dbPath = tempDbPath();
|
|
418
|
+
openDatabase(dbPath);
|
|
419
|
+
|
|
420
|
+
const { basePath } = createTempProject();
|
|
421
|
+
|
|
422
|
+
insertMilestone({ id: 'M001' });
|
|
423
|
+
insertSlice({ id: 'S01', milestoneId: 'M001' });
|
|
424
|
+
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' });
|
|
425
|
+
|
|
426
|
+
const seedParams = makeValidSliceParams();
|
|
427
|
+
const seeded = await handleCompleteSlice(seedParams, basePath);
|
|
428
|
+
assertTrue(!('error' in seeded), 'seed completion should succeed');
|
|
429
|
+
if ('error' in seeded) {
|
|
430
|
+
cleanupDir(basePath);
|
|
431
|
+
cleanup(dbPath);
|
|
432
|
+
throw new Error('seed completion unexpectedly failed');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const seededSummary = fs.readFileSync(seeded.summaryPath, 'utf-8');
|
|
436
|
+
transaction(() => {
|
|
437
|
+
updateSliceStatus('M001', 'S01', 'pending', undefined);
|
|
438
|
+
setSliceSummaryMd('M001', 'S01', seededSummary, '');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const backfillParams = makeValidSliceParams();
|
|
442
|
+
delete (backfillParams as Partial<CompleteSliceParams>).requirementsAdvanced;
|
|
443
|
+
delete (backfillParams as Partial<CompleteSliceParams>).requirementsValidated;
|
|
444
|
+
delete (backfillParams as Partial<CompleteSliceParams>).requirementsInvalidated;
|
|
445
|
+
const backfilled = await handleCompleteSlice(backfillParams as CompleteSliceParams, basePath);
|
|
446
|
+
assertTrue(!('error' in backfilled), 'backfill completion should succeed');
|
|
447
|
+
if (!('error' in backfilled)) {
|
|
448
|
+
const summary = fs.readFileSync(backfilled.summaryPath, 'utf-8');
|
|
449
|
+
assertMatch(summary, /## Requirements Advanced/, 'summary should include advanced requirements heading');
|
|
450
|
+
assertMatch(summary, /- R001 — Handler validates task completion/, 'advanced requirement should be backfilled from summary markdown');
|
|
451
|
+
|
|
452
|
+
const sliceAfterBackfill = getSlice('M001', 'S01');
|
|
453
|
+
assertTrue(sliceAfterBackfill !== null, 'slice should exist after backfill');
|
|
454
|
+
assertMatch(
|
|
455
|
+
sliceAfterBackfill!.full_summary_md,
|
|
456
|
+
/- R001 — Handler validates task completion/,
|
|
457
|
+
'DB full_summary_md should persist the backfilled advanced requirement',
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
cleanupDir(basePath);
|
|
462
|
+
cleanup(dbPath);
|
|
463
|
+
}
|
|
464
|
+
|
|
410
465
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
411
466
|
// complete-slice: PROJECT refresh uses DB-backed artifact tool.
|
|
412
467
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
|
|
12
12
|
import test from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { resolveDispatch } from "../auto-dispatch.ts";
|
|
18
18
|
import type { DispatchContext } from "../auto-dispatch.ts";
|
|
19
|
+
import type { AutoSession } from "../auto/session.ts";
|
|
19
20
|
import type { GSDState } from "../types.ts";
|
|
21
|
+
import { enableDebug, disableDebug, getDebugLogPath } from "../debug-logger.ts";
|
|
20
22
|
|
|
21
23
|
function makeState(overrides: Partial<GSDState> = {}): GSDState {
|
|
22
24
|
return {
|
|
@@ -42,6 +44,27 @@ function makeContext(basePath: string, stateOverrides?: Partial<GSDState>): Disp
|
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
function makeContextFor(
|
|
48
|
+
basePath: string,
|
|
49
|
+
mid: string,
|
|
50
|
+
sid: string,
|
|
51
|
+
tid: string,
|
|
52
|
+
session?: Partial<AutoSession>,
|
|
53
|
+
): DispatchContext {
|
|
54
|
+
return {
|
|
55
|
+
basePath,
|
|
56
|
+
mid,
|
|
57
|
+
midTitle: "Test Milestone",
|
|
58
|
+
state: makeState({
|
|
59
|
+
activeMilestone: { id: mid, title: "Test Milestone" },
|
|
60
|
+
activeSlice: { id: sid, title: "Second Slice" },
|
|
61
|
+
activeTask: { id: tid, title: "First Task" },
|
|
62
|
+
}),
|
|
63
|
+
prefs: undefined,
|
|
64
|
+
session: session as AutoSession | undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
// ─── Scaffold helpers ──────────────────────────────────────────────────────
|
|
46
69
|
|
|
47
70
|
function scaffoldSlicePlan(basePath: string, mid: string, sid: string): void {
|
|
@@ -118,6 +141,57 @@ test("dispatch: present task plan proceeds to execute-task normally", async (t)
|
|
|
118
141
|
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
|
119
142
|
});
|
|
120
143
|
|
|
144
|
+
test("dispatch: executing recovery checks active milestone worktree task plans before re-dispatching plan-slice", async (t) => {
|
|
145
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-6192-"));
|
|
146
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
147
|
+
|
|
148
|
+
scaffoldMilestoneContext(tmp, "M002");
|
|
149
|
+
scaffoldSlicePlan(tmp, "M002", "S03");
|
|
150
|
+
|
|
151
|
+
const worktreeRoot = join(tmp, ".gsd", "worktrees", "M002");
|
|
152
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
153
|
+
writeFileSync(join(worktreeRoot, ".git"), "gitdir: /tmp/fake-worktree-gitdir\n");
|
|
154
|
+
scaffoldMilestoneContext(worktreeRoot, "M002");
|
|
155
|
+
scaffoldSlicePlan(worktreeRoot, "M002", "S03");
|
|
156
|
+
scaffoldTaskPlan(worktreeRoot, "M002", "S03", "T01");
|
|
157
|
+
|
|
158
|
+
const ctx = makeContext(tmp);
|
|
159
|
+
const result = await resolveDispatch(ctx);
|
|
160
|
+
|
|
161
|
+
assert.equal(result.action, "dispatch");
|
|
162
|
+
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
|
|
163
|
+
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
|
164
|
+
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
|
|
165
|
+
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("dispatch: active session worktree task plan wins over missing original-root task plan", async (t) => {
|
|
169
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-worktree-artifact-root-"));
|
|
170
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
171
|
+
|
|
172
|
+
scaffoldMilestoneContext(tmp, "M004");
|
|
173
|
+
scaffoldSlicePlan(tmp, "M004", "S02");
|
|
174
|
+
|
|
175
|
+
const worktreeRoot = join(tmp, ".gsd", "worktrees", "M004");
|
|
176
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
177
|
+
scaffoldMilestoneContext(worktreeRoot, "M004");
|
|
178
|
+
scaffoldSlicePlan(worktreeRoot, "M004", "S02");
|
|
179
|
+
scaffoldTaskPlan(worktreeRoot, "M004", "S02", "T01");
|
|
180
|
+
|
|
181
|
+
const ctx = makeContextFor(tmp, "M004", "S02", "T01", {
|
|
182
|
+
basePath: worktreeRoot,
|
|
183
|
+
originalBasePath: tmp,
|
|
184
|
+
currentMilestoneId: "M004",
|
|
185
|
+
});
|
|
186
|
+
const result = await resolveDispatch(ctx);
|
|
187
|
+
|
|
188
|
+
assert.equal(result.action, "dispatch");
|
|
189
|
+
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
|
|
190
|
+
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
|
|
191
|
+
assert.ok(result.action === "dispatch" && result.unitId === "M004/S02/T01",
|
|
192
|
+
`unitId should be M004/S02/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
|
|
193
|
+
});
|
|
194
|
+
|
|
121
195
|
test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async (t) => {
|
|
122
196
|
// Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write).
|
|
123
197
|
// Dispatch should still re-dispatch plan-slice, not hard-stop.
|
|
@@ -138,3 +212,39 @@ test("dispatch: plan-slice recovery loop — second call after plan-slice still
|
|
|
138
212
|
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
|
|
139
213
|
"should keep dispatching plan-slice until task plans appear");
|
|
140
214
|
});
|
|
215
|
+
|
|
216
|
+
test("dispatch: missing task plan recovery logs root/worktree diagnostic when debug enabled — issue #6194", async (t) => {
|
|
217
|
+
// The diagnostic exists to surface root/worktree artifact-path mismatches
|
|
218
|
+
// when the recovery rule fires. It must report the paths that were checked
|
|
219
|
+
// so a stuck session can be traced — not just that recovery happened.
|
|
220
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-6194-"));
|
|
221
|
+
t.after(() => rmSync(tmp, { recursive: true, force: true }));
|
|
222
|
+
|
|
223
|
+
scaffoldMilestoneContext(tmp, "M002");
|
|
224
|
+
scaffoldSlicePlan(tmp, "M002", "S03");
|
|
225
|
+
|
|
226
|
+
enableDebug(tmp);
|
|
227
|
+
t.after(() => disableDebug());
|
|
228
|
+
|
|
229
|
+
const ctx = makeContext(tmp);
|
|
230
|
+
const result = await resolveDispatch(ctx);
|
|
231
|
+
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
|
|
232
|
+
"recovery rule must fire for the diagnostic to be exercised");
|
|
233
|
+
|
|
234
|
+
const logPath = getDebugLogPath();
|
|
235
|
+
assert.ok(logPath, "debug log path should be set while debug is enabled");
|
|
236
|
+
|
|
237
|
+
const entry = readFileSync(logPath!, "utf8")
|
|
238
|
+
.trim()
|
|
239
|
+
.split("\n")
|
|
240
|
+
.filter(Boolean)
|
|
241
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
242
|
+
.find((e) => e.event === "dispatch-missing-task-plan-recovery");
|
|
243
|
+
|
|
244
|
+
assert.ok(entry, "diagnostic event should be logged when recovery fires in debug mode");
|
|
245
|
+
assert.equal(entry!.basePathUsedForArtifactChecks, tmp);
|
|
246
|
+
assert.equal(entry!.artifactExists, false, "task plan is genuinely absent");
|
|
247
|
+
assert.equal(entry!.projectionArtifactExists, false, "projection task plan is genuinely absent");
|
|
248
|
+
assert.equal(typeof entry!.expectedTaskPlanPath, "string");
|
|
249
|
+
assert.equal(typeof entry!.projectionTaskPlanPath, "string");
|
|
250
|
+
});
|
package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
openDatabase,
|
|
32
32
|
closeDatabase,
|
|
33
33
|
insertMilestone,
|
|
34
|
+
insertAssessment,
|
|
34
35
|
insertSlice,
|
|
35
36
|
insertTask,
|
|
36
37
|
getTask,
|
|
@@ -280,6 +281,16 @@ function makeMilestoneParams(milestoneId: string): Record<string, unknown> {
|
|
|
280
281
|
};
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
function insertPassingMilestoneValidation(milestoneId: string): void {
|
|
285
|
+
insertAssessment({
|
|
286
|
+
path: `.gsd/milestones/${milestoneId}/${milestoneId}-VALIDATION.md`,
|
|
287
|
+
milestoneId,
|
|
288
|
+
status: "pass",
|
|
289
|
+
scope: "milestone-validation",
|
|
290
|
+
fullContent: "# Validation\n\nverdict: PASS",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
283
294
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
284
295
|
// Test Suite
|
|
285
296
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -424,6 +435,7 @@ describe("state-machine-live-validation", () => {
|
|
|
424
435
|
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Impl", status: "complete" });
|
|
425
436
|
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", title: "Test", status: "complete" });
|
|
426
437
|
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", title: "Impl", status: "complete" });
|
|
438
|
+
insertPassingMilestoneValidation("M001");
|
|
427
439
|
|
|
428
440
|
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
|
|
429
441
|
assert.ok(!("error" in result), `expected success, got: ${JSON.stringify(result)}`);
|
|
@@ -528,6 +540,7 @@ describe("state-machine-live-validation", () => {
|
|
|
528
540
|
base = createFullFixture();
|
|
529
541
|
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
530
542
|
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
543
|
+
insertPassingMilestoneValidation("M001");
|
|
531
544
|
|
|
532
545
|
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
|
|
533
546
|
assert.ok("error" in result);
|
|
@@ -542,6 +555,7 @@ describe("state-machine-live-validation", () => {
|
|
|
542
555
|
insertSlice({ id: "S02", milestoneId: "M001", status: "in_progress" });
|
|
543
556
|
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
544
557
|
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
|
|
558
|
+
insertPassingMilestoneValidation("M001");
|
|
545
559
|
|
|
546
560
|
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
|
|
547
561
|
assert.ok("error" in result);
|
|
@@ -555,6 +569,7 @@ describe("state-machine-live-validation", () => {
|
|
|
555
569
|
// Slice marked complete but task is still pending — simulates inconsistent state
|
|
556
570
|
insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
|
|
557
571
|
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
572
|
+
insertPassingMilestoneValidation("M001");
|
|
558
573
|
|
|
559
574
|
const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
|
|
560
575
|
assert.ok("error" in result);
|
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
|
|
8
|
+
import { _handlePausedSessionResumeRecoveryForTest } from "../auto.ts";
|
|
8
9
|
import { assessInterruptedSession } from "../interrupted-session.ts";
|
|
9
10
|
import {
|
|
10
11
|
openDatabase,
|
|
@@ -186,6 +187,43 @@ test("direct /gsd auto source only resumes paused-session metadata for recoverab
|
|
|
186
187
|
assert.ok(source.includes('|| !!freshStartAssessment.lock'));
|
|
187
188
|
});
|
|
188
189
|
|
|
190
|
+
test("direct /gsd auto skips paused-session replay when recovered unit already completed", async () => {
|
|
191
|
+
const base = makeTmpBase();
|
|
192
|
+
try {
|
|
193
|
+
writeRoadmap(base, false);
|
|
194
|
+
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
|
|
195
|
+
const tasksDir = join(sliceDir, "tasks");
|
|
196
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
197
|
+
writeFileSync(
|
|
198
|
+
join(sliceDir, "S01-PLAN.md"),
|
|
199
|
+
[
|
|
200
|
+
"# S01: Test Slice",
|
|
201
|
+
"",
|
|
202
|
+
"## Tasks",
|
|
203
|
+
"",
|
|
204
|
+
"- [ ] **T01: First task** `est:1h`",
|
|
205
|
+
].join("\n"),
|
|
206
|
+
"utf-8",
|
|
207
|
+
);
|
|
208
|
+
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.\n", "utf-8");
|
|
209
|
+
|
|
210
|
+
const state = {
|
|
211
|
+
pausedSessionFile: join(base, ".gsd", "activity", "paused-session.jsonl"),
|
|
212
|
+
currentUnit: { type: "plan-slice", id: "M001/S01" },
|
|
213
|
+
pausedUnitType: null,
|
|
214
|
+
pausedUnitId: null,
|
|
215
|
+
pendingCrashRecovery: null,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = _handlePausedSessionResumeRecoveryForTest(base, state);
|
|
219
|
+
assert.equal(result.skippedReplay, true);
|
|
220
|
+
assert.equal(state.pausedSessionFile, null);
|
|
221
|
+
assert.equal(state.pendingCrashRecovery, null);
|
|
222
|
+
} finally {
|
|
223
|
+
cleanup(base);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
189
227
|
test("auto module imports successfully after interrupted-session changes", async () => {
|
|
190
228
|
const mod = await import(`../auto.ts?ts=${Date.now()}-${Math.random()}`);
|
|
191
229
|
assert.equal(typeof mod.startAuto, "function");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, before, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync, lstatSync, realpathSync, mkdirSync, symlinkSync, renameSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, lstatSync, realpathSync, mkdirSync, symlinkSync, renameSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { execSync } from "node:child_process";
|
|
@@ -228,4 +228,31 @@ test('validateProjectId accepts valid values', () => {
|
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
test('ensureGsdSymlink prefers marker directory when marker/computed identities diverge and both have state (#5685)', () => {
|
|
232
|
+
const repo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-split-brain-")));
|
|
233
|
+
run("git init -b main", repo);
|
|
234
|
+
run('git config user.name "Pi Test"', repo);
|
|
235
|
+
run('git config user.email "pi@example.com"', repo);
|
|
236
|
+
writeFileSync(join(repo, "README.md"), "# Split Brain Test\n", "utf-8");
|
|
237
|
+
run("git add README.md", repo);
|
|
238
|
+
run('git commit -m "init"', repo);
|
|
239
|
+
|
|
240
|
+
const computedPath = externalGsdRoot(repo);
|
|
241
|
+
mkdirSync(computedPath, { recursive: true });
|
|
242
|
+
writeFileSync(join(computedPath, "computed-state.txt"), "computed\n", "utf-8");
|
|
243
|
+
|
|
244
|
+
const markerId = "marker-state-id";
|
|
245
|
+
const markerPath = join(stateDir, "projects", markerId);
|
|
246
|
+
mkdirSync(markerPath, { recursive: true });
|
|
247
|
+
writeFileSync(join(markerPath, "marker-state.txt"), "marker\n", "utf-8");
|
|
248
|
+
writeFileSync(join(repo, ".gsd-id"), `${markerId}\n`, "utf-8");
|
|
249
|
+
|
|
250
|
+
const resolved = ensureGsdSymlink(repo);
|
|
251
|
+
assert.deepStrictEqual(resolved, markerPath, "marker-backed state directory is preferred in split-brain condition");
|
|
252
|
+
assert.deepStrictEqual(realpathSync(join(repo, ".gsd")), realpathSync(markerPath), ".gsd symlink resolves to marker-backed state directory");
|
|
253
|
+
assert.deepStrictEqual(readFileSync(join(repo, ".gsd-id"), "utf-8").trim(), markerId, ".gsd-id marker persists the marker-backed identity");
|
|
254
|
+
|
|
255
|
+
rmSync(repo, { recursive: true, force: true });
|
|
256
|
+
});
|
|
257
|
+
|
|
231
258
|
});
|
|
@@ -25,6 +25,9 @@ import {
|
|
|
25
25
|
isSessionLockHeld,
|
|
26
26
|
} from '../session-lock.ts';
|
|
27
27
|
import { gsdRoot } from '../paths.ts';
|
|
28
|
+
import { openDatabase, closeDatabase, _getAdapter } from "../gsd-db.ts";
|
|
29
|
+
import { registerAutoWorker, getAutoWorker } from "../db/auto-workers.ts";
|
|
30
|
+
import { normalizeRealPath } from "../paths.ts";
|
|
28
31
|
import { describe, test } from 'node:test';
|
|
29
32
|
import assert from 'node:assert/strict';
|
|
30
33
|
|
|
@@ -94,6 +97,38 @@ describe('session-lock-regression', async () => {
|
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
// ─── 2b. Dead lock PID is marked stopping in workers table ────────────
|
|
101
|
+
console.log('\n=== 2b. dead lock PID marks worker stopping ===');
|
|
102
|
+
{
|
|
103
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
104
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
108
|
+
const projectRoot = normalizeRealPath(base);
|
|
109
|
+
const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
|
|
110
|
+
const deadPid = 99999;
|
|
111
|
+
writeFileSync(join(gsdRoot(base), "auto.lock"), JSON.stringify({
|
|
112
|
+
pid: deadPid,
|
|
113
|
+
startedAt: new Date().toISOString(),
|
|
114
|
+
unitType: "starting",
|
|
115
|
+
unitId: "bootstrap",
|
|
116
|
+
unitStartedAt: new Date().toISOString(),
|
|
117
|
+
}));
|
|
118
|
+
// Align worker PID with stale lock metadata.
|
|
119
|
+
_getAdapter()?.prepare("UPDATE workers SET pid = :pid WHERE worker_id = :id")
|
|
120
|
+
.run({ ":pid": deadPid, ":id": workerId });
|
|
121
|
+
|
|
122
|
+
const result = acquireSessionLock(base);
|
|
123
|
+
assert.ok(result.acquired, "acquire recovers stale lock");
|
|
124
|
+
assert.equal(getAutoWorker(workerId)?.status, "stopping");
|
|
125
|
+
releaseSessionLock(base);
|
|
126
|
+
} finally {
|
|
127
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
128
|
+
rmSync(base, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
97
132
|
// ─── 3. updateSessionLock preserves lock data ─────────────────────────
|
|
98
133
|
console.log('\n=== 3. updateSessionLock writes metadata ===');
|
|
99
134
|
{
|
|
@@ -7,12 +7,20 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
import {
|
|
8
8
|
_hasEmptyAgentEndContent,
|
|
9
9
|
_handleSessionSwitchAgentEnd,
|
|
10
|
+
handleAgentEnd,
|
|
10
11
|
isBareClaudeCodeStreamAbortPlaceholder,
|
|
11
12
|
isClaudeCodeSessionSwitchAbortMessage,
|
|
12
13
|
} from "../bootstrap/agent-end-recovery.js";
|
|
14
|
+
import { _setAutoActiveForTest } from "../auto.js";
|
|
13
15
|
import { shouldIgnoreAgentEndForActiveUnit } from "../auto/unit-runner-events.js";
|
|
16
|
+
import { _resetPendingResolve, _setCurrentResolve } from "../auto/resolve.js";
|
|
14
17
|
import type { ErrorContext } from "../auto/types.js";
|
|
15
18
|
|
|
19
|
+
test.afterEach(() => {
|
|
20
|
+
_setAutoActiveForTest(false);
|
|
21
|
+
_resetPendingResolve();
|
|
22
|
+
});
|
|
23
|
+
|
|
16
24
|
test("user-abort message during session-switch is dropped (not propagated as cancellation)", () => {
|
|
17
25
|
// The Anthropic SDK emits this exact string when newSession() aborts an
|
|
18
26
|
// in-flight stream during a unit-to-unit session transition. Before the fix
|
|
@@ -113,6 +121,34 @@ test("late bare Claude Code stream-aborted placeholder is classified as internal
|
|
|
113
121
|
);
|
|
114
122
|
});
|
|
115
123
|
|
|
124
|
+
test("mid-unit bare Claude Code stream-aborted placeholder resolves the unit", async () => {
|
|
125
|
+
// A placeholder without an active session-switch grace window belongs to the
|
|
126
|
+
// current unit. Resolving it prevents auto-mode from hanging while still
|
|
127
|
+
// allowing the next unit to run.
|
|
128
|
+
const results: unknown[] = [];
|
|
129
|
+
const warnings: string[] = [];
|
|
130
|
+
_setAutoActiveForTest(true);
|
|
131
|
+
_setCurrentResolve((result) => results.push(result));
|
|
132
|
+
|
|
133
|
+
const event = {
|
|
134
|
+
messages: [{
|
|
135
|
+
stopReason: "aborted",
|
|
136
|
+
content: [{ type: "text", text: "Claude Code stream aborted by caller" }],
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
await handleAgentEnd({} as any, event, {
|
|
141
|
+
ui: {
|
|
142
|
+
notify: (message: string, level: string) => {
|
|
143
|
+
if (level === "warning") warnings.push(message);
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
} as any);
|
|
147
|
+
|
|
148
|
+
assert.deepEqual(results, [{ status: "completed", event }]);
|
|
149
|
+
assert.deepEqual(warnings, ["Claude Code stream aborted mid-unit (no diagnostic). Continuing."]);
|
|
150
|
+
});
|
|
151
|
+
|
|
116
152
|
test("typed session-transition abort events are classified as internal", () => {
|
|
117
153
|
assert.equal(
|
|
118
154
|
shouldIgnoreAgentEndForActiveUnit({
|
|
@@ -205,6 +241,8 @@ test("missing agent_end content is classified as empty abort content", () => {
|
|
|
205
241
|
assert.equal(_hasEmptyAgentEndContent(undefined), true);
|
|
206
242
|
assert.equal(_hasEmptyAgentEndContent(null), true);
|
|
207
243
|
assert.equal(_hasEmptyAgentEndContent([]), true);
|
|
244
|
+
assert.equal(_hasEmptyAgentEndContent([{ type: "text", text: "" }]), true);
|
|
245
|
+
assert.equal(_hasEmptyAgentEndContent([{ type: "text", text: " " }]), true);
|
|
208
246
|
assert.equal(_hasEmptyAgentEndContent([{ type: "text", text: "partial" }]), false);
|
|
209
247
|
});
|
|
210
248
|
|