@gajae-code/coding-agent 0.12.20 → 0.12.21
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 +7 -0
- package/dist/types/session/session-manager.d.ts +1 -0
- package/dist/types/tools/todo-contract.d.ts +10 -1
- package/package.json +8 -8
- package/src/session/agent-session.ts +19 -7
- package/src/session/session-manager.ts +37 -12
- package/src/tools/descriptors.test.ts +51 -0
- package/src/tools/todo-contract.ts +46 -9
- package/src/tools/todo-write.ts +2 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.12.21] - 2026-08-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Strict session resume now sanitizes stale replay metadata in memory without mutating the selected transcript, then publishes the terminal resume breadcrumb only after the first durable user-visible write. Declined auto-continuations restore their deferred predecessor terminal instead of dropping client settlement evidence.
|
|
9
|
+
- Todo completion reminders now honor `todo.reminders.max` across an interactive turn rather than stopping after the first reminder.
|
|
10
|
+
- Hidden `nextTurn` messages that trigger a follow-up model call no longer deadlock the predecessor prompt during post-prompt settlement.
|
|
11
|
+
|
|
5
12
|
## [0.12.20] - 2026-08-09
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
@@ -880,6 +880,7 @@ export declare const SessionManagerTestHooks: {
|
|
|
880
880
|
materializedCacheMaxBytesOverride?: number;
|
|
881
881
|
beforeResidentTransitionIndexBuild?: () => void;
|
|
882
882
|
afterForkSnapshot?: () => void | Promise<void>;
|
|
883
|
+
afterForkTranscriptPublished?: () => void | Promise<void>;
|
|
883
884
|
beforeEphemeralArtifactManagerInstall?: (dir: string) => void | Promise<void>;
|
|
884
885
|
beforePersistPatchFence?: (attempt: number) => void;
|
|
885
886
|
};
|
|
@@ -7,9 +7,18 @@ import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
|
|
|
7
7
|
*/
|
|
8
8
|
export declare const TODO_DONE_ALIASES: readonly ["complete", "completed"];
|
|
9
9
|
export declare function isDoneAlias(value: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Legal `op` values. Exported so the todo_write schema and this contract read
|
|
12
|
+
* from one list: a rejection that names a vocabulary the schema no longer
|
|
13
|
+
* accepts is exactly the drift this file exists to prevent.
|
|
14
|
+
*/
|
|
15
|
+
export declare const TODO_OPS: readonly ["init", "start", "done", "rm", "drop", "append", "note"];
|
|
10
16
|
/**
|
|
11
17
|
* Single source of truth for todo_write pre-coercion validation. Every
|
|
12
18
|
* rejection carries a code so the caller can surface an actionable correction
|
|
13
|
-
* instead of a bare "raw arguments rejected" message
|
|
19
|
+
* instead of a bare "raw arguments rejected" message, and every entry-level
|
|
20
|
+
* rejection names the failing `ops` index. Rejection stays batch-atomic, but a
|
|
21
|
+
* caller told which entry failed can resubmit the rest instead of rebuilding
|
|
22
|
+
* the payload from memory — or, as observed, silently abandoning the list.
|
|
14
23
|
*/
|
|
15
24
|
export declare function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/coding-agent",
|
|
4
|
-
"version": "0.12.
|
|
4
|
+
"version": "0.12.21",
|
|
5
5
|
"description": "Gajae Code CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
55
55
|
"@babel/parser": "^7.29.3",
|
|
56
56
|
"@mozilla/readability": "^0.6.0",
|
|
57
|
-
"@gajae-code/stats": "0.12.
|
|
58
|
-
"@gajae-code/agent-core": "0.12.
|
|
59
|
-
"@gajae-code/ai": "0.12.
|
|
60
|
-
"@gajae-code/bridge-client": "0.12.
|
|
61
|
-
"@gajae-code/natives": "0.12.
|
|
62
|
-
"@gajae-code/tui": "0.12.
|
|
63
|
-
"@gajae-code/utils": "0.12.
|
|
57
|
+
"@gajae-code/stats": "0.12.21",
|
|
58
|
+
"@gajae-code/agent-core": "0.12.21",
|
|
59
|
+
"@gajae-code/ai": "0.12.21",
|
|
60
|
+
"@gajae-code/bridge-client": "0.12.21",
|
|
61
|
+
"@gajae-code/natives": "0.12.21",
|
|
62
|
+
"@gajae-code/tui": "0.12.21",
|
|
63
|
+
"@gajae-code/utils": "0.12.21",
|
|
64
64
|
"@puppeteer/browsers": "^2.13.0",
|
|
65
65
|
"@types/turndown": "5.0.6",
|
|
66
66
|
"@xterm/headless": "^6.0.0",
|
|
@@ -753,7 +753,7 @@ type AutoCompactionTerminalStatus =
|
|
|
753
753
|
* forced compaction retry).
|
|
754
754
|
*/
|
|
755
755
|
type PreSubmitBuilder = {
|
|
756
|
-
build: () => Promise<AgentMessage[]>;
|
|
756
|
+
build: () => Promise<AgentMessage[] | null>;
|
|
757
757
|
reset: () => void;
|
|
758
758
|
};
|
|
759
759
|
|
|
@@ -2629,6 +2629,7 @@ export class AgentSession {
|
|
|
2629
2629
|
}
|
|
2630
2630
|
async #settleEndedInFlight(promptWait?: "publication" | "full"): Promise<void> {
|
|
2631
2631
|
const flushError = this.#endInFlight();
|
|
2632
|
+
const predecessorPromptStillInFlight = this.#promptInFlightCount > 0;
|
|
2632
2633
|
if (promptWait === "publication") {
|
|
2633
2634
|
await this.#agentEndPublicationPromise;
|
|
2634
2635
|
} else if (promptWait === "full") {
|
|
@@ -2636,7 +2637,10 @@ export class AgentSession {
|
|
|
2636
2637
|
await this.#waitForPostPromptRecovery();
|
|
2637
2638
|
await this.#agentEndPublicationPromise;
|
|
2638
2639
|
}
|
|
2639
|
-
|
|
2640
|
+
// A post-prompt continuation runs before its predecessor prompt returns. Its
|
|
2641
|
+
// publication must settle, but session-wide settlement can still be owned by
|
|
2642
|
+
// that predecessor; waiting here would make each prompt wait on the other.
|
|
2643
|
+
if (!predecessorPromptStillInFlight) await this.#waitForSessionSettlement();
|
|
2640
2644
|
if (flushError) throw flushError;
|
|
2641
2645
|
}
|
|
2642
2646
|
|
|
@@ -8932,8 +8936,12 @@ export class AgentSession {
|
|
|
8932
8936
|
// Phase A products (including the plan reference) remain stable.
|
|
8933
8937
|
let attemptMessages: AgentMessage[] | undefined;
|
|
8934
8938
|
|
|
8935
|
-
const buildPreSubmit = async (): Promise<AgentMessage[]> => {
|
|
8939
|
+
const buildPreSubmit = async (): Promise<AgentMessage[] | null> => {
|
|
8936
8940
|
this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
|
|
8941
|
+
if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
|
|
8942
|
+
this.#resetInjectedContextSignatures();
|
|
8943
|
+
return null;
|
|
8944
|
+
}
|
|
8937
8945
|
if (!phaseACompleted) {
|
|
8938
8946
|
phaseACompleted = true;
|
|
8939
8947
|
// Phase A (one-time side-effectful products; runs once).
|
|
@@ -9094,6 +9102,10 @@ export class AgentSession {
|
|
|
9094
9102
|
message.role,
|
|
9095
9103
|
);
|
|
9096
9104
|
}
|
|
9105
|
+
if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
|
|
9106
|
+
this.#resetInjectedContextSignatures();
|
|
9107
|
+
return null;
|
|
9108
|
+
}
|
|
9097
9109
|
attemptMessages = messages;
|
|
9098
9110
|
return messages;
|
|
9099
9111
|
};
|
|
@@ -9123,10 +9135,6 @@ export class AgentSession {
|
|
|
9123
9135
|
if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
|
|
9124
9136
|
},
|
|
9125
9137
|
};
|
|
9126
|
-
if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
|
|
9127
|
-
this.#resetInjectedContextSignatures();
|
|
9128
|
-
return;
|
|
9129
|
-
}
|
|
9130
9138
|
await this.#promptAgentWithIdleRetry(preSubmit, agentPromptOptions, predecessorAgentEndHold, {
|
|
9131
9139
|
signal: preflightSignal,
|
|
9132
9140
|
resourceRunId: this.#runResourceLeaseContext.getStore()?.resourceRunId,
|
|
@@ -16160,6 +16168,10 @@ export class AgentSession {
|
|
|
16160
16168
|
continuationHold = undefined;
|
|
16161
16169
|
try {
|
|
16162
16170
|
const messages = await preSubmit.build();
|
|
16171
|
+
if (messages === null) {
|
|
16172
|
+
this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
|
|
16173
|
+
return;
|
|
16174
|
+
}
|
|
16163
16175
|
if (!preflightAccepted) {
|
|
16164
16176
|
await seam?.onPreflightAccepted?.();
|
|
16165
16177
|
preflightAccepted = true;
|
|
@@ -6216,6 +6216,7 @@ export const SessionManagerTestHooks: {
|
|
|
6216
6216
|
materializedCacheMaxBytesOverride?: number;
|
|
6217
6217
|
beforeResidentTransitionIndexBuild?: () => void;
|
|
6218
6218
|
afterForkSnapshot?: () => void | Promise<void>;
|
|
6219
|
+
afterForkTranscriptPublished?: () => void | Promise<void>;
|
|
6219
6220
|
beforeEphemeralArtifactManagerInstall?: (dir: string) => void | Promise<void>;
|
|
6220
6221
|
beforePersistPatchFence?: (attempt: number) => void;
|
|
6221
6222
|
} = {};
|
|
@@ -6310,6 +6311,7 @@ export class SessionManager {
|
|
|
6310
6311
|
#sessionFile: string | undefined;
|
|
6311
6312
|
#flushed: boolean = false;
|
|
6312
6313
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
6314
|
+
#readOnlyResume = false;
|
|
6313
6315
|
#ensuredOnDisk: boolean = false;
|
|
6314
6316
|
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
6315
6317
|
#recoveryPromotionTranscriptPath: string | undefined;
|
|
@@ -8754,6 +8756,7 @@ export class SessionManager {
|
|
|
8754
8756
|
publishedSha256,
|
|
8755
8757
|
};
|
|
8756
8758
|
}
|
|
8759
|
+
await SessionManagerTestHooks.afterForkTranscriptPublished?.();
|
|
8757
8760
|
if (forkArtifactPublication?.kind === "managed") {
|
|
8758
8761
|
forkArtifactPublication.store.verifyRootSecurity();
|
|
8759
8762
|
forkArtifactPublication.store.assertBound();
|
|
@@ -13429,6 +13432,7 @@ export class SessionManager {
|
|
|
13429
13432
|
*/
|
|
13430
13433
|
async ensureOnDisk(): Promise<void> {
|
|
13431
13434
|
if (!this.persist || !this.#sessionFile) return;
|
|
13435
|
+
if (this.#readOnlyResume) return;
|
|
13432
13436
|
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
|
|
13433
13437
|
await this.#rewriteFile();
|
|
13434
13438
|
this.#ensuredOnDisk = true;
|
|
@@ -13480,7 +13484,7 @@ export class SessionManager {
|
|
|
13480
13484
|
await this.#closePersistWriterInternal();
|
|
13481
13485
|
this.#flushed = true;
|
|
13482
13486
|
}
|
|
13483
|
-
if (this.#needsFullRewriteOnNextPersist) await this.#rewriteFileContents();
|
|
13487
|
+
if (this.#needsFullRewriteOnNextPersist && !this.#readOnlyResume) await this.#rewriteFileContents();
|
|
13484
13488
|
});
|
|
13485
13489
|
this.#retireEphemeralArtifacts();
|
|
13486
13490
|
await this.#drainEphemeralArtifactCleanups();
|
|
@@ -14388,6 +14392,8 @@ export class SessionManager {
|
|
|
14388
14392
|
if (records.length === 0) return;
|
|
14389
14393
|
if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
|
|
14390
14394
|
if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
|
|
14395
|
+
const sessionFile = this.#sessionFile;
|
|
14396
|
+
const publishResumeBreadcrumb = this.#readOnlyResume;
|
|
14391
14397
|
await this.#queuePersistTask(async () => {
|
|
14392
14398
|
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
14393
14399
|
const token = this.#capturePersistenceInputToken();
|
|
@@ -14396,8 +14402,12 @@ export class SessionManager {
|
|
|
14396
14402
|
this.#needsFullRewriteOnNextPersist ||
|
|
14397
14403
|
!this.#flushed ||
|
|
14398
14404
|
(header?.version ?? 1) < CURRENT_SESSION_VERSION
|
|
14399
|
-
)
|
|
14400
|
-
|
|
14405
|
+
) {
|
|
14406
|
+
await this.#rewriteFileContents();
|
|
14407
|
+
if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, sessionFile);
|
|
14408
|
+
this.#readOnlyResume = false;
|
|
14409
|
+
return;
|
|
14410
|
+
}
|
|
14401
14411
|
const persistedRecords = records.map(record =>
|
|
14402
14412
|
record.type === "entry_patch"
|
|
14403
14413
|
? (prepareEntryForPersistenceSync(
|
|
@@ -14411,27 +14421,40 @@ export class SessionManager {
|
|
|
14411
14421
|
: record,
|
|
14412
14422
|
);
|
|
14413
14423
|
SessionManagerTestHooks.beforePersistPatchFence?.(attempt);
|
|
14424
|
+
let persisted = false;
|
|
14414
14425
|
const written = this.#withSessionPersistenceFenceSync(() => {
|
|
14415
14426
|
if (!this.#persistenceInputTokenMatches(token)) return false;
|
|
14416
14427
|
if (this.destination.kind === "managed") {
|
|
14417
14428
|
this.#appendManagedRecordsSync(persistedRecords);
|
|
14429
|
+
persisted = true;
|
|
14418
14430
|
return true;
|
|
14419
14431
|
}
|
|
14420
14432
|
const writer = this.#ensurePersistWriter();
|
|
14421
14433
|
if (!writer) {
|
|
14422
|
-
this.#rewriteFile()
|
|
14434
|
+
void this.#rewriteFile()
|
|
14435
|
+
.then(() => {
|
|
14436
|
+
if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, sessionFile);
|
|
14437
|
+
this.#readOnlyResume = false;
|
|
14438
|
+
})
|
|
14439
|
+
.catch(() => {});
|
|
14423
14440
|
return true;
|
|
14424
14441
|
}
|
|
14425
14442
|
for (const persistedRecord of persistedRecords) writer.writeSync(persistedRecord);
|
|
14443
|
+
persisted = true;
|
|
14426
14444
|
return true;
|
|
14427
14445
|
});
|
|
14428
|
-
if (written)
|
|
14446
|
+
if (written) {
|
|
14447
|
+
if (publishResumeBreadcrumb && persisted) writeTerminalBreadcrumb(this.cwd, sessionFile);
|
|
14448
|
+
if (persisted) this.#readOnlyResume = false;
|
|
14449
|
+
return;
|
|
14450
|
+
}
|
|
14429
14451
|
}
|
|
14430
14452
|
throw new Error("session_persistence_input_stale");
|
|
14431
14453
|
});
|
|
14432
14454
|
}
|
|
14433
14455
|
_persist(entry: SessionEntry): void {
|
|
14434
14456
|
if (!this.persist || !this.#sessionFile) return;
|
|
14457
|
+
const publishResumeBreadcrumb = this.#readOnlyResume;
|
|
14435
14458
|
if (this.#persistError) throw this.#persistError;
|
|
14436
14459
|
|
|
14437
14460
|
// Normally we wait for the first assistant message before persisting to avoid
|
|
@@ -14456,6 +14479,8 @@ export class SessionManager {
|
|
|
14456
14479
|
// test-level tempDir cleanup.
|
|
14457
14480
|
try {
|
|
14458
14481
|
this.#rewriteFileSync();
|
|
14482
|
+
if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
14483
|
+
this.#readOnlyResume = false;
|
|
14459
14484
|
} catch (err) {
|
|
14460
14485
|
this.#recordPersistError(err);
|
|
14461
14486
|
throw this.#persistError ?? toError(err);
|
|
@@ -14468,6 +14493,7 @@ export class SessionManager {
|
|
|
14468
14493
|
// landing immediately after this call. Image externalization (rare) runs via
|
|
14469
14494
|
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
14470
14495
|
// line referencing them is written.
|
|
14496
|
+
let persisted = false;
|
|
14471
14497
|
try {
|
|
14472
14498
|
this.#withSessionPersistenceFenceSync(() => {
|
|
14473
14499
|
if (this.destination.kind === "managed") {
|
|
@@ -14478,6 +14504,7 @@ export class SessionManager {
|
|
|
14478
14504
|
);
|
|
14479
14505
|
const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
|
|
14480
14506
|
this.#appendManagedRecordsSync([persistedEntry]);
|
|
14507
|
+
persisted = true;
|
|
14481
14508
|
return;
|
|
14482
14509
|
}
|
|
14483
14510
|
const writer = this.#ensurePersistWriter();
|
|
@@ -14496,7 +14523,10 @@ export class SessionManager {
|
|
|
14496
14523
|
);
|
|
14497
14524
|
const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
|
|
14498
14525
|
writer.writeSync(persistedEntry);
|
|
14526
|
+
persisted = true;
|
|
14499
14527
|
});
|
|
14528
|
+
if (publishResumeBreadcrumb && persisted) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
14529
|
+
if (persisted) this.#readOnlyResume = false;
|
|
14500
14530
|
} catch (err) {
|
|
14501
14531
|
this.#recordPersistError(err);
|
|
14502
14532
|
throw this.#persistError ?? toError(err);
|
|
@@ -17535,18 +17565,13 @@ export class SessionManager {
|
|
|
17535
17565
|
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
|
|
17536
17566
|
manager.#sessionMemoryMode = sessionMemoryMode;
|
|
17537
17567
|
await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
|
|
17568
|
+
manager.#readOnlyResume = true;
|
|
17538
17569
|
const ownershipInspection = revalidateResumeSessionIdentity(sessionPath, storage, inspected.identity);
|
|
17539
17570
|
if (ownershipInspection.kind === "error") {
|
|
17540
17571
|
await manager.close();
|
|
17541
17572
|
return ownershipInspection;
|
|
17542
17573
|
}
|
|
17543
|
-
|
|
17544
|
-
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
17545
|
-
writeTerminalBreadcrumb(manager.cwd, sessionPath);
|
|
17546
|
-
} catch (error) {
|
|
17547
|
-
await manager.close();
|
|
17548
|
-
throw error;
|
|
17549
|
-
}
|
|
17574
|
+
manager.#sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
17550
17575
|
return { kind: "opened", manager };
|
|
17551
17576
|
}
|
|
17552
17577
|
|
|
@@ -485,6 +485,8 @@ describe("tool descriptor compatibility gate", () => {
|
|
|
485
485
|
{ ops: "not-an-array" },
|
|
486
486
|
{ ops: [{ op: "done", task: "x" }], stray: true },
|
|
487
487
|
{ _i: "Tracking progress", ops: [{ op: "done", task: "x" }] },
|
|
488
|
+
{ ops: [{ op: "retitle", task: "x", newTask: "y" }] },
|
|
489
|
+
{ ops: [{ op: "add", phase: "P", items: ["a"] }] },
|
|
488
490
|
];
|
|
489
491
|
for (const payload of payloads) {
|
|
490
492
|
expect(lazy.rawArgumentValidation(payload)).toEqual(eager.rawArgumentValidation(payload));
|
|
@@ -508,6 +510,55 @@ describe("tool descriptor compatibility gate", () => {
|
|
|
508
510
|
).toThrow("todo_write done and drop entries require a task or phase target");
|
|
509
511
|
});
|
|
510
512
|
|
|
513
|
+
test("deferred and loaded todo_write reject a bad payload with the same message", async () => {
|
|
514
|
+
const session = makeSession({ "tools.discoveryMode": "all" });
|
|
515
|
+
session.hasUI = true;
|
|
516
|
+
const lazy = (await createTools(session)).find(tool => tool.name === "todo_write");
|
|
517
|
+
const eager = await BUILTIN_TOOL_DESCRIPTORS.todo_write.load(session);
|
|
518
|
+
if (!lazy || !eager) throw new Error("expected deferred and loaded todo_write tools");
|
|
519
|
+
const capture = (run: () => unknown): string => {
|
|
520
|
+
try {
|
|
521
|
+
run();
|
|
522
|
+
} catch (error) {
|
|
523
|
+
return error instanceof Error ? error.message : String(error);
|
|
524
|
+
}
|
|
525
|
+
throw new Error("expected a raw-argument rejection");
|
|
526
|
+
};
|
|
527
|
+
const call = (arguments_: Record<string, unknown>) => ({
|
|
528
|
+
id: "call-parity",
|
|
529
|
+
type: "toolCall" as const,
|
|
530
|
+
name: "todo_write",
|
|
531
|
+
arguments: arguments_,
|
|
532
|
+
});
|
|
533
|
+
// The observed incident: four usable entries discarded alongside one
|
|
534
|
+
// invented op. Whichever path runs first must say the same thing, or the
|
|
535
|
+
// caller that hit the cold registry sees none of the added guidance.
|
|
536
|
+
const invalidOp = call({
|
|
537
|
+
ops: [
|
|
538
|
+
{ op: "append", phase: "Work", items: ["ship it"] },
|
|
539
|
+
{ op: "done", task: "first" },
|
|
540
|
+
{ op: "done", task: "second" },
|
|
541
|
+
{ op: "done", task: "third" },
|
|
542
|
+
{ op: "retitle", task: "third", newTask: "fourth" },
|
|
543
|
+
],
|
|
544
|
+
});
|
|
545
|
+
const lazyOpMessage = capture(() => validateToolArguments(lazy, invalidOp));
|
|
546
|
+
expect(lazyOpMessage).toBe(capture(() => validateToolArguments(eager, invalidOp)));
|
|
547
|
+
expect(lazyOpMessage).toContain("todo_write operation entries require a known op value");
|
|
548
|
+
expect(lazyOpMessage).toContain("op must be one of: init, start, done, rm, drop, append, note");
|
|
549
|
+
expect(lazyOpMessage).toContain("ops[4]");
|
|
550
|
+
|
|
551
|
+
const unknownKey = call({
|
|
552
|
+
ops: [
|
|
553
|
+
{ op: "done", task: "x" },
|
|
554
|
+
{ op: "done", task: "y", bogusOpKey: 1 },
|
|
555
|
+
],
|
|
556
|
+
});
|
|
557
|
+
const lazyKeyMessage = capture(() => validateToolArguments(lazy, unknownKey));
|
|
558
|
+
expect(lazyKeyMessage).toBe(capture(() => validateToolArguments(eager, unknownKey)));
|
|
559
|
+
expect(lazyKeyMessage).toContain('rejected key: "bogusOpKey" (ops[1])');
|
|
560
|
+
});
|
|
561
|
+
|
|
511
562
|
test("deferred ask validator recovers the canonical round-zero pair", async () => {
|
|
512
563
|
const session = makeSession({ "tools.discoveryMode": "all" });
|
|
513
564
|
session.hasUI = true;
|
|
@@ -22,6 +22,23 @@ export function isDoneAlias(value: string): boolean {
|
|
|
22
22
|
return (TODO_DONE_ALIASES as readonly string[]).includes(value);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Legal `op` values. Exported so the todo_write schema and this contract read
|
|
27
|
+
* from one list: a rejection that names a vocabulary the schema no longer
|
|
28
|
+
* accepts is exactly the drift this file exists to prevent.
|
|
29
|
+
*/
|
|
30
|
+
export const TODO_OPS = ["init", "start", "done", "rm", "drop", "append", "note"] as const;
|
|
31
|
+
|
|
32
|
+
function isKnownOp(value: string): boolean {
|
|
33
|
+
return (TODO_OPS as readonly string[]).includes(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The whole vocabulary, not a guess at which entry the caller meant. Static
|
|
38
|
+
* text, so clamping a rejection detail can never truncate it away.
|
|
39
|
+
*/
|
|
40
|
+
const TODO_OP_VOCABULARY_HINT = `op must be one of: ${TODO_OPS.join(", ")}`;
|
|
41
|
+
|
|
25
42
|
/**
|
|
26
43
|
* `_i` is the intent field injected into tool schemas by the agent loop. The
|
|
27
44
|
* loop strips it before validation, but replayed, bridged, and directly
|
|
@@ -46,15 +63,18 @@ const TODO_OP_KEYS_WITH_ALIASES = new Set([...TODO_OP_KEYS, "content"]);
|
|
|
46
63
|
*/
|
|
47
64
|
const TODO_KEY_CORRECTIONS = new Map<string, string>([
|
|
48
65
|
["note", 'note is an op, not a key; note operations require both "task" and "text"'],
|
|
66
|
+
["newTask", "there is no rename op; re-run init with the corrected list to rename a task"],
|
|
67
|
+
["tasks", 'tasks is not a key; append operations take "items"'],
|
|
49
68
|
]);
|
|
50
69
|
|
|
51
70
|
function unknownKeys(value: object, allowed: Set<string>): string[] {
|
|
52
71
|
return Object.keys(value).filter(key => !allowed.has(key));
|
|
53
72
|
}
|
|
54
73
|
|
|
55
|
-
function keyRejectionDetail(keys: readonly string[]): RawArgumentRejectionDetail {
|
|
74
|
+
function keyRejectionDetail(keys: readonly string[], location?: string): RawArgumentRejectionDetail {
|
|
56
75
|
const hints = keys.map(key => TODO_KEY_CORRECTIONS.get(key)).filter((hint): hint is string => hint !== undefined);
|
|
57
|
-
|
|
76
|
+
const parts = location === undefined ? hints : [location, ...hints];
|
|
77
|
+
return parts.length > 0 ? { rejectedKeys: keys, hint: parts.join("; ") } : { rejectedKeys: keys };
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -64,15 +84,32 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
64
84
|
/**
|
|
65
85
|
* Single source of truth for todo_write pre-coercion validation. Every
|
|
66
86
|
* rejection carries a code so the caller can surface an actionable correction
|
|
67
|
-
* instead of a bare "raw arguments rejected" message
|
|
87
|
+
* instead of a bare "raw arguments rejected" message, and every entry-level
|
|
88
|
+
* rejection names the failing `ops` index. Rejection stays batch-atomic, but a
|
|
89
|
+
* caller told which entry failed can resubmit the rest instead of rebuilding
|
|
90
|
+
* the payload from memory — or, as observed, silently abandoning the list.
|
|
68
91
|
*/
|
|
69
92
|
export function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult {
|
|
70
93
|
const unknownRootKeys = unknownKeys(arguments_, TODO_WRITE_KEYS);
|
|
71
94
|
if (unknownRootKeys.length > 0)
|
|
72
95
|
return { outcome: "reject", code: "todo-write-unknown-root-key", detail: keyRejectionDetail(unknownRootKeys) };
|
|
73
96
|
if (!Array.isArray(arguments_.ops)) return { outcome: "passthrough" };
|
|
74
|
-
|
|
97
|
+
const ops: readonly unknown[] = arguments_.ops;
|
|
98
|
+
for (const [index, entry] of ops.entries()) {
|
|
75
99
|
if (!isPlainRecord(entry)) continue;
|
|
100
|
+
const location = `ops[${index}]`;
|
|
101
|
+
// The op value is checked before the entry keys. An entry that invents
|
|
102
|
+
// both an op and a key is an unknown-op problem, and reporting the key
|
|
103
|
+
// first tells the caller which keys are legal while leaving it to guess
|
|
104
|
+
// that the operation it reached for does not exist at all.
|
|
105
|
+
const rawOp = entry.op;
|
|
106
|
+
const op = typeof rawOp === "string" && isDoneAlias(rawOp) ? "done" : rawOp;
|
|
107
|
+
if (typeof op === "string" && !isKnownOp(op))
|
|
108
|
+
return {
|
|
109
|
+
outcome: "reject",
|
|
110
|
+
code: "todo-write-unknown-op-value",
|
|
111
|
+
detail: { rejectedKeys: [op], hint: `${location}; ${TODO_OP_VOCABULARY_HINT}` },
|
|
112
|
+
};
|
|
76
113
|
// `content` is normalized to `task` by the schema preprocessor, so it must
|
|
77
114
|
// not be rejected here as an unknown key first.
|
|
78
115
|
const unknownEntryKeys = unknownKeys(entry, TODO_OP_KEYS_WITH_ALIASES);
|
|
@@ -80,23 +117,23 @@ export function validateRawTodoArguments(arguments_: Record<string, unknown>): R
|
|
|
80
117
|
return {
|
|
81
118
|
outcome: "reject",
|
|
82
119
|
code: "todo-write-unknown-op-entry-key",
|
|
83
|
-
detail: keyRejectionDetail(unknownEntryKeys),
|
|
120
|
+
detail: keyRejectionDetail(unknownEntryKeys, location),
|
|
84
121
|
};
|
|
85
|
-
const op = typeof entry.op === "string" && isDoneAlias(entry.op) ? "done" : entry.op;
|
|
86
122
|
const target = entry.task ?? entry.content;
|
|
87
123
|
if ((op === "done" || op === "drop") && !target && !entry.phase) {
|
|
88
|
-
return { outcome: "reject", code: "todo-write-done-drop-requires-target" };
|
|
124
|
+
return { outcome: "reject", code: "todo-write-done-drop-requires-target", detail: { hint: location } };
|
|
89
125
|
}
|
|
90
126
|
const list = entry.list;
|
|
91
127
|
if (!Array.isArray(list)) continue;
|
|
92
|
-
|
|
128
|
+
const items: readonly unknown[] = list;
|
|
129
|
+
for (const [itemIndex, item] of items.entries()) {
|
|
93
130
|
if (!isPlainRecord(item)) continue;
|
|
94
131
|
const unknownItemKeys = unknownKeys(item, TODO_INIT_ENTRY_KEYS);
|
|
95
132
|
if (unknownItemKeys.length > 0)
|
|
96
133
|
return {
|
|
97
134
|
outcome: "reject",
|
|
98
135
|
code: "todo-write-unknown-init-entry-key",
|
|
99
|
-
detail: keyRejectionDetail(unknownItemKeys),
|
|
136
|
+
detail: keyRejectionDetail(unknownItemKeys, `${location}.list[${itemIndex}]`),
|
|
100
137
|
};
|
|
101
138
|
}
|
|
102
139
|
}
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { ToolSession } from "../sdk";
|
|
|
11
11
|
import type { SessionEntry } from "../session/session-manager";
|
|
12
12
|
import { renderStatusLine, renderTreeList } from "../tui";
|
|
13
13
|
import { PREVIEW_LIMITS } from "./render-utils";
|
|
14
|
-
import { isDoneAlias, validateRawTodoArguments } from "./todo-contract";
|
|
14
|
+
import { isDoneAlias, TODO_OPS, validateRawTodoArguments } from "./todo-contract";
|
|
15
15
|
|
|
16
16
|
// =============================================================================
|
|
17
17
|
// Types
|
|
@@ -47,10 +47,7 @@ export interface TodoWriteToolDetails {
|
|
|
47
47
|
// =============================================================================
|
|
48
48
|
|
|
49
49
|
const TodoOp = z
|
|
50
|
-
.preprocess(
|
|
51
|
-
value => (typeof value === "string" && isDoneAlias(value) ? "done" : value),
|
|
52
|
-
z.enum(["init", "start", "done", "rm", "drop", "append", "note"] as const),
|
|
53
|
-
)
|
|
50
|
+
.preprocess(value => (typeof value === "string" && isDoneAlias(value) ? "done" : value), z.enum(TODO_OPS))
|
|
54
51
|
.describe('operation to apply; use "done" to complete a task');
|
|
55
52
|
|
|
56
53
|
const InitListEntry = z.object({
|