Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
33 changes: 26 additions & 7 deletions src/tasks-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,40 @@ export async function updateSessionTitle(
try {
const con = new Sqlite(TASKS_INDEX_PATH, { timeout: 5000 });
try {
const row = con.prepare("SELECT title_overridden FROM tasks WHERE task_id=?").get(taskId) as
{ title_overridden: number } | undefined;
const row = con
.prepare("SELECT title_overridden, meta_json FROM tasks WHERE task_id=?")
.get(taskId) as { title_overridden: number; meta_json: string } | undefined;
if (!row) return false;

// The ZCode App reads title from meta_json first (falling back to the
// title column only when meta_json fails to parse). If we update only the
// column, the App keeps showing the stale meta_json title (empty at create
// time). So we patch meta_json.title in both branches below.
let metaJson: string;
try {
const meta = JSON.parse(row.meta_json ?? "{}") as Record<string, unknown>;
meta["title"] = trimmed;
metaJson = JSON.stringify(meta);
} catch {
// meta_json corrupt/unparseable — the App will fall back to the title
// column anyway, so skip the meta_json write rather than guessing.
metaJson = row.meta_json ?? "{}";
}
Comment on lines +191 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the user has already overridden the title (row.title_overridden === 1), unconditionally setting meta["title"] = trimmed (the auto-generated title) will overwrite the user's custom title in meta_json. Since the App reads the title from meta_json first, this will cause the App to display the auto-generated title instead of the user's overridden title, breaking the override behavior.

To fix this, we should select the title column as well, and if title_overridden === 1, preserve the existing user-defined title in meta_json.

Suggested change
const row = con
.prepare("SELECT title_overridden, meta_json FROM tasks WHERE task_id=?")
.get(taskId) as { title_overridden: number; meta_json: string } | undefined;
if (!row) return false;
// The ZCode App reads title from meta_json first (falling back to the
// title column only when meta_json fails to parse). If we update only the
// column, the App keeps showing the stale meta_json title (empty at create
// time). So we patch meta_json.title in both branches below.
let metaJson: string;
try {
const meta = JSON.parse(row.meta_json ?? "{}") as Record<string, unknown>;
meta["title"] = trimmed;
metaJson = JSON.stringify(meta);
} catch {
// meta_json corrupt/unparseable — the App will fall back to the title
// column anyway, so skip the meta_json write rather than guessing.
metaJson = row.meta_json ?? "{}";
}
const row = con
.prepare("SELECT title, title_overridden, meta_json FROM tasks WHERE task_id=?")
.get(taskId) as { title: string; title_overridden: number; meta_json: string | null } | undefined;
if (!row) return false;
// The ZCode App reads title from meta_json first (falling back to the
// title column only when meta_json fails to parse). If we update only the
// column, the App keeps showing the stale meta_json title (empty at create
// time). So we patch meta_json.title in both branches below.
let metaJson: string;
try {
const meta = JSON.parse(row.meta_json ?? "{}") as Record<string, unknown>;
meta["title"] = row.title_overridden === 1 ? row.title : trimmed;
metaJson = JSON.stringify(meta);
} catch {
// meta_json corrupt/unparseable — the App will fall back to the title
// column anyway, so skip the meta_json write rather than guessing.
metaJson = row.meta_json ?? "{}";
}


if (row.title_overridden === 1) {
// User overrode the title → respect it, but still refresh searchable_text.
// User overrode the title → respect the column value, but still refresh
// searchable_text and sync meta_json.title for consistency.
con
.prepare("UPDATE tasks SET updated_at=?, searchable_text=? WHERE task_id=?")
.run(Date.now(), search, taskId);
.prepare("UPDATE tasks SET updated_at=?, searchable_text=?, meta_json=? WHERE task_id=?")
.run(Date.now(), search, metaJson, taskId);
return true;
}
con
.prepare(
"UPDATE tasks SET title=?, updated_at=?, searchable_text=? WHERE task_id=? AND title_overridden=0",
"UPDATE tasks SET title=?, updated_at=?, searchable_text=?, meta_json=? " +
"WHERE task_id=? AND title_overridden=0",
)
.run(trimmed, Date.now(), search, taskId);
.run(trimmed, Date.now(), search, metaJson, taskId);
} finally {
con.close();
}
Expand Down
49 changes: 41 additions & 8 deletions tests/tasks-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,47 +98,52 @@ function makeStatement(sql: string) {
},
};
}
// SELECT title_overridden FROM tasks WHERE task_id=?
// SELECT title_overridden, meta_json FROM tasks WHERE task_id=?
if (/^SELECT title_overridden/i.test(sql)) {
return {
get(taskId: string) {
for (const r of rows.values()) {
if (r.task_id === taskId) {
return { title_overridden: r.title_overridden } as const;
return {
title_overridden: r.title_overridden,
meta_json: r.meta_json,
} as const;
}
}
return undefined;
},
};
}
Comment on lines +101 to 116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the mock statement to support the new SELECT title, title_overridden, meta_json query format and return the title property, ensuring that the unit tests continue to pass.

Suggested change
// SELECT title_overridden, meta_json FROM tasks WHERE task_id=?
if (/^SELECT title_overridden/i.test(sql)) {
return {
get(taskId: string) {
for (const r of rows.values()) {
if (r.task_id === taskId) {
return { title_overridden: r.title_overridden } as const;
return {
title_overridden: r.title_overridden,
meta_json: r.meta_json,
} as const;
}
}
return undefined;
},
};
}
// SELECT title, title_overridden, meta_json FROM tasks WHERE task_id=?
if (/^SELECT (title, )?title_overridden/i.test(sql)) {
return {
get(taskId: string) {
for (const r of rows.values()) {
if (r.task_id === taskId) {
return {
title: r.title,
title_overridden: r.title_overridden,
meta_json: r.meta_json,
} as const;
}
}
return undefined;
},
};
}

// UPDATE tasks SET title=?, updated_at=?, searchable_text=?
// UPDATE tasks SET title=?, updated_at=?, searchable_text=?, meta_json=?
// WHERE task_id=? AND title_overridden=0
if (/^UPDATE tasks SET title/i.test(sql)) {
return {
run(title: string, updatedAt: number, searchable: string, taskId: string) {
run(title: string, updatedAt: number, searchable: string, metaJson: string, taskId: string) {
let changes = 0;
for (const r of rows.values()) {
if (r.task_id === taskId && r.title_overridden === 0) {
r.title = title;
r.updated_at = updatedAt;
r.searchable_text = searchable;
r.meta_json = metaJson;
changes++;
}
}
return { changes };
},
};
}
// UPDATE tasks SET updated_at=?, searchable_text=? WHERE task_id=?
// UPDATE tasks SET updated_at=?, searchable_text=?, meta_json=? WHERE task_id=?
// (title_overridden=1 branch: keep user title, still refresh searchable_text)
if (/^UPDATE tasks SET updated_at=\?, searchable_text/i.test(sql)) {
return {
run(updatedAt: number, searchable: string, taskId: string) {
run(updatedAt: number, searchable: string, metaJson: string, taskId: string) {
let changes = 0;
for (const r of rows.values()) {
if (r.task_id === taskId) {
r.updated_at = updatedAt;
r.searchable_text = searchable;
r.meta_json = metaJson;
changes++;
}
}
Expand Down Expand Up @@ -250,7 +255,7 @@ describe("tasks-index session sync", () => {
});

describe("updateSessionTitle", () => {
it("updates title + searchable_text when title_overridden=0", async () => {
it("updates title + searchable_text + meta_json when title_overridden=0", async () => {
await upsertSessionTask({
workspaceKey: "/ws",
taskId: "sess_4",
Expand All @@ -262,6 +267,8 @@ describe("tasks-index session sync", () => {
expect(r.title).toBe("new title");
expect(r.searchable_text).toBe("new title");
expect(r.title_overridden).toBe(0);
// The App reads title from meta_json first — must be in sync.
expect(JSON.parse(r.meta_json).title).toBe("new title");
});

it("uses the full prompt text as searchable_text when provided", async () => {
Expand Down Expand Up @@ -303,7 +310,7 @@ describe("tasks-index session sync", () => {
expect(r.searchable_text.length).toBe(80);
});

it("respects user title override but still refreshes searchable_text", async () => {
it("respects user title override but still refreshes searchable_text + meta_json", async () => {
await upsertSessionTask({
workspaceKey: "/ws",
taskId: "sess_6",
Expand All @@ -320,12 +327,38 @@ describe("tasks-index session sync", () => {
expect(r.title).toBe("user kept name");
// But searchable_text is still updated (not user-controlled).
expect(r.searchable_text).toBe("search body");
// meta_json.title is synced to the auto title for consistency (the App
// reads meta_json first, but with title_overridden=1 the column wins).
expect(JSON.parse(r.meta_json).title).toBe("auto title");
});

it("returns false when the session row does not exist", async () => {
const ok = await updateSessionTitle("never_created", "title");
expect(ok).toBe(false);
expect(rows.size).toBe(0);
});

it("regression: meta_json.title is updated so the App shows the new title", async () => {
// The ZCode App reads title from meta_json first, falling back to the
// title column only when meta_json is unparseable. At session/create the
// meta_json.title is "" (empty). Without patching meta_json on title
// update, the App keeps showing the empty title even though the column
// was updated.
await upsertSessionTask({
workspaceKey: "/ws",
taskId: "sess_meta",
title: "",
});
const before = JSON.parse(rows.get("/ws\u0000sess_meta")!.meta_json);
expect(before.title).toBe(""); // empty at create time

await updateSessionTitle("sess_meta", "fix the login bug");
const after = JSON.parse(rows.get("/ws\u0000sess_meta")!.meta_json);
// App reads this → must reflect the new title.
expect(after.title).toBe("fix the login bug");
// Other meta fields must survive the patch.
expect(after.taskId).toBe("sess_meta");
expect(after.mode).toBe("build");
});
});
});
Loading