Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions agent/tools/write_card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ export default defineTool({
.describe(
"ТОЛЬКО для SUPERSEDE: одна строка о прежней истине, переносимая в ## History, " +
"в формате 'YYYY-MM-DD: факт' (своя дата сохраняется; без неё ставится сегодняшняя). " +
"На ADD отбрасывается как шум, на UPDATE/NOOP — ошибка.",
"На ADD отбрасывается как шум, на UPDATE/NOOP непустое значение — ошибка " +
"(пустая строка равна отсутствию поля).",
),
confidence: z
.enum(["EXTRACTED", "INFERRED", "AMBIGUOUS"])
Expand Down Expand Up @@ -299,8 +300,13 @@ export default defineTool({
const file = id.file;
const rel = relative(VAULT(), file).split(sep).join("/");

// Пустой/пробельный history_entry ничего не вытесняет и не подделывает архив: для UPDATE
// и NOOP он равен отсутствующему — так же, как SUPERSEDE читает его через trim(). Модели,
// заполняющие все поля схемы, шлют "" и без этого зацикливаются на одном отказе.
const historyEntry = history_entry?.trim() ? history_entry : undefined;

if (operation === "NOOP") {
if (replace_body || history_entry !== undefined) {
if (replace_body || historyEntry !== undefined) {
return {
ok: false,
error: "NOOP не принимает replace_body или history_entry.",
Expand Down Expand Up @@ -345,7 +351,7 @@ export default defineTool({
});
// history_entry несёт вытесненную истину, которой у ADD ещё нет: там он шум и молча
// отбрасывается (с записью в журнал), а у UPDATE — попытка подделать архив.
if (history_entry !== undefined && effectiveOperation === "UPDATE") {
if (historyEntry !== undefined && effectiveOperation === "UPDATE") {
return {
ok: false,
error: "history_entry допустим только для SUPERSEDE.",
Expand Down Expand Up @@ -403,7 +409,9 @@ export default defineTool({
replaceBody: replace_body === true,
// Сырая operation: по её отсутствию mergeCard узнаёт легаси-путь replace_body.
operation,
historyEntry: history_entry,
// ADD получает сырое поле: пустую строку он отбрасывает сам и фиксирует это в журнале.
historyEntry:
effectiveOperation === "ADD" ? history_entry : historyEntry,
});
if (action !== "noop") atomicWrite(file, content);
if (ignoredHistoryEntry) logIgnoredHistoryEntry();
Expand Down
73 changes: 73 additions & 0 deletions scripts/write-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,79 @@ test("ADD отбрасывает шумовой history_entry один раз,
}
});

test("UPDATE и NOOP читают пустой history_entry как отсутствующий, непустой — по-прежнему отказ", async () => {
const base = {
operation: "ADD",
type: "note",
title: "Blank history entry",
description: "пустое поле не подделывает архив",
tags: ["note", "blank"],
body: "Текущая истина.",
};
const created = await call(base);
assert.equal(created.ok, true);
// Тела разные намеренно: повтор уже лежащего факта даёт "updated", а не "merged".
for (const [blank, fact] of [
["", "Первый новый факт."],
[" ", "Второй новый факт."],
]) {
const updated = await call({
...base,
operation: "UPDATE",
body: fact,
history_entry: blank,
});
assert.equal(updated.ok, true, updated.error);
assert.equal(updated.action, "merged");
const noop = await call({
...base,
operation: "NOOP",
history_entry: blank,
});
assert.equal(noop.ok, true, noop.error);
assert.equal(noop.action, "noop");
}
const out = read(created.file);
assert.doesNotMatch(out, /^## History$/gm);
assert.match(out, /^## Log$/m);

const before = out;
const forged = await call({
...base,
operation: "UPDATE",
body: "Ещё факт.",
history_entry: "2026-01-01: прежняя истина",
});
assert.equal(forged.ok, false);
assert.match(forged.error, /допустим только для SUPERSEDE/);
assert.equal(read(created.file), before);
const forgedNoop = await call({
...base,
operation: "NOOP",
history_entry: "2026-01-01: прежняя истина",
});
assert.equal(forgedNoop.ok, false);
assert.match(forgedNoop.error, /NOOP не принимает/);
assert.equal(
read(created.file),
before,
"rejected NOOP leaves the card byte-identical",
);

const superseded = await call({
...base,
operation: "SUPERSEDE",
body: "Новая истина.",
history_entry: "2026-08-26: Текущая истина.",
});
assert.equal(superseded.ok, true, superseded.error);
assert.equal(superseded.action, "replaced");
const after = read(created.file);
assert.match(after, /^## History$/m);
assert.match(after, /Новая истина/);
assert.match(after, /2026-08-26: Текущая истина/);
});

test("ADD отбрасывает пустой и длинный однострочный history_entry, не создавая пустых полей", async () => {
const previousWarn = console.warn;
const warnings: string[] = [];
Expand Down