fix(skills): persist draft skill attachments across window restore

Extend the bounded restorable attachment codec with native skill IDs and display names. Skills now survive capture, serialization and hydration without blocking window-state writes; removed skill attachments stay removed.

Addresses gatekeeper P1 on PR #791. Add round-trip, post-restore send, removal and malformed/oversized source coverage. UI typecheck and 56 focused codec/state/skill regressions pass.
This commit is contained in:
Pascal André 2026-09-26 18:24:28 +02:00
parent 0014bbc962
commit ea3b4ef6df
No known key found for this signature in database
2 changed files with 23 additions and 1 deletions

View file

@ -3,6 +3,7 @@ import { test } from "node:test"
import { createSkillAttachment } from "../types/attachment"
import { promptSkills } from "./prompt-skills"
import { normalizeSessionMessage } from "../stores/message-v2/normalizers"
import { hydrateRestorableAttachment, normalizeRestorableAttachmentRecord, serializeDraftAttachments } from "../stores/client-state-attachments-codec"
test("explicit skill ids are deduplicated without injecting skill content", () => {
const skill = createSkillAttachment("opaque/id", "Review")
@ -24,3 +25,18 @@ test("historical skill labels survive normalization without retaining skill body
assert.deepEqual(result.message.parts[1], { id: "message-skill-0", type: "skill", skillId: "skill", name: "Review", sessionID: "session", messageID: "message" })
assert.equal(JSON.stringify(result.message).includes("large native skill content"), false)
})
test("skill drafts survive persistent window-state capture and removal", () => {
const attachment = createSkillAttachment("review", "Review")
const saved = serializeDraftAttachments({ session: "review this" }, { session: [attachment] })
const reloaded = normalizeRestorableAttachmentRecord(JSON.parse(JSON.stringify(saved.attachments)), saved.drafts)!
const hydrated = hydrateRestorableAttachment(reloaded.attachments.session[0])!
assert.deepEqual(hydrated, attachment)
assert.deepEqual(promptSkills([hydrated], saved.drafts.session), [{ id: "review" }])
const removed = serializeDraftAttachments(saved.drafts, { session: [] })
assert.equal(removed.attachments.session, undefined)
assert.equal(removed.drafts.session, "review this")
for (const source of [{ type: "skill", id: "x".repeat(513), name: "Review" }, { type: "skill", id: "review", name: "x".repeat(1025) }, { type: "skill", id: "", name: "Review" }]) {
assert.equal(normalizeRestorableAttachmentRecord({ session: [{ ...attachment, source }] }, saved.drafts)!.attachments.session, undefined)
}
})

View file

@ -4,6 +4,7 @@ export type RestorableAttachmentSource =
| { type: "file"; path: string; mime: string; data?: string }
| { type: "text"; value: string }
| { type: "agent"; name: string }
| { type: "skill"; id: string; name: string }
| { type: "symbol"; path: string; name: string; kind: number; range: { start: Position; end: Position } }
interface Position {
@ -84,6 +85,11 @@ function normalizeSource(
const name = takeString(value.name, MAX_DISPLAY)
return name === undefined ? undefined : { type: "agent", name }
}
if (value.type === "skill") {
const id = takeString(value.id, MAX_ID)
const name = takeString(value.name, MAX_DISPLAY)
return id === undefined || name === undefined ? undefined : { type: "skill", id, name }
}
const path = takeString(value.path, MAX_PATH)
if (value.type === "file") {
@ -104,7 +110,7 @@ function normalizeSource(
function normalizeAttachment(value: unknown): RestorableAttachment | undefined {
if (!isRecord(value)) return
if (!["file", "text", "symbol", "agent"].includes(String(value.type))) return
if (!["file", "text", "symbol", "agent", "skill"].includes(String(value.type))) return
const id = takeString(value.id, MAX_ID)
const display = takeString(value.display, MAX_DISPLAY)