fix: preserve skill attachment semantics

This commit is contained in:
Kit Langton
2026-08-07 23:13:41 -04:00
parent 574b11617d
commit 405ea2fcc8
15 changed files with 195 additions and 44 deletions
+1
View File
@@ -197,6 +197,7 @@ export type Endpoint5_13Input = {
readonly model?: Model.Ref | undefined
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
@@ -435,6 +435,7 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
@@ -639,6 +639,7 @@ export function make(options: ClientOptions) {
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
@@ -3520,6 +3520,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
@@ -3539,6 +3543,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["command"]
@@ -3558,6 +3566,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["arguments"]
@@ -3577,6 +3589,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agent"]
@@ -3596,6 +3612,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["model"]
@@ -3615,6 +3635,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["files"]
@@ -3634,9 +3658,36 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["skills"]
readonly delivery?: {
readonly id?: string | null
readonly command: string
@@ -3653,6 +3704,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
@@ -3672,6 +3727,10 @@ export type SessionCommandInput = {
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
+1
View File
@@ -312,6 +312,7 @@ export function fromPromise(plugin: Plugin) {
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
arguments: input.arguments ?? undefined,
delivery: input.delivery ?? undefined,
resume: input.resume ?? undefined,
+59 -21
View File
@@ -144,6 +144,13 @@ type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID:
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
export class SkillAttachmentError extends Schema.TaggedErrorClass<SkillAttachmentError>()(
"Session.SkillAttachmentError",
{
skill: Skill.ID,
message: Schema.String,
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"Session.DestinationNotFoundError",
@@ -222,7 +229,10 @@ export interface Interface {
metadata?: Record<string, unknown>
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
}) => Effect.Effect<
SessionPending.User,
NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError | SkillAttachmentError
>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
@@ -237,6 +247,7 @@ export interface Interface {
model?: Model.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<
@@ -245,6 +256,7 @@ export interface Interface {
| PromptConflictError
| AttachmentError
| SkillNotFoundError
| SkillAttachmentError
| Command.NotFoundError
| Command.EvaluationError
>
@@ -572,16 +584,32 @@ const layer = Layer.effect(
// image attachment actually needs the resizer.
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const previous =
input.id && input.skills?.length
? yield* SessionPending.existing(db, {
id: messageID,
sessionID: input.sessionID,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
: undefined
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
skills,
previous?.type === "user" ? previous.data.skills : undefined,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionPending.Message.make({
type: "user",
data: { ...prompt, metadata: input.metadata },
delivery: input.delivery ?? "steer",
delivery,
})
const admitted = yield* SessionPending.admit(db, bus, {
id: messageID,
@@ -641,6 +669,7 @@ const layer = Layer.effect(
text: evaluated.text,
files: input.files,
agents: input.agents,
skills: input.skills,
delivery: input.delivery,
resume: input.resume,
})
@@ -904,30 +933,39 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface>,
previous: Prompt["skills"],
) {
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = requested?.length
? yield* Effect.gen(function* () {
const available = yield* (yield* skills).list()
const attachments = Array.from(new Map(requested.map((attachment) => [attachment.id, attachment])).values())
return yield* Effect.forEach(attachments, (attachment) => {
const skill = available.find((item) => item.id === attachment.id)
if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
return Skill.modelOutput(fs, skill).pipe(
Effect.map((output) => ({
id: skill.id,
name: skill.name,
text: output.output,
mention: attachment.mention,
})),
)
})
})
: undefined
const reusable =
requested &&
previous &&
JSON.stringify(requested) === JSON.stringify(previous.map((skill) => ({ id: skill.id, mention: skill.mention })))
? previous
: undefined
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
if (reusable) return reusable
const available = yield* (yield* skills).list()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const skill = available.find((item) => item.id === attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
const output = yield* Skill.modelOutput(fs, skill).pipe(
Effect.mapError((error) => new SkillAttachmentError({ skill: skill.id, message: String(error) })),
)
return {
id: skill.id,
name: skill.name,
text: output.output,
mention: attachment.mention,
}
}),
)
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
})
+17 -6
View File
@@ -130,6 +130,15 @@ const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(func
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const existing = Effect.fn("SessionPending.existing")(function* (
db: DatabaseService,
input: PendingRef & { readonly delivery: Delivery },
) {
const pending = yield* find(db, input.id)
if (pending !== undefined) return pending
return yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery)
})
export const admit = Effect.fn("SessionPending.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
@@ -139,13 +148,15 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
readonly input: Message
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
const stored = yield* existing(db, {
id: request.id,
sessionID: request.sessionID,
delivery: request.input.delivery,
})
if (stored !== undefined) {
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return stored
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
+1 -3
View File
@@ -63,9 +63,7 @@ export const modelOutput = Effect.fn("Skill.modelOutput")(function* (fs: FSUtil.
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs
.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[]))))
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
+32 -10
View File
@@ -23,17 +23,16 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const effectSkill = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
})
let listedSkills: Skill.Info[] = [effectSkill]
const skills = Layer.mock(Skill.Service, {
list: () =>
Effect.succeed([
Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
}),
]),
list: () => Effect.sync(() => listedSkills),
})
const locations = Layer.effect(
LocationServiceMap.Service,
@@ -56,6 +55,29 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("reconciles a promoted skill prompt after the live skill disappears", () =>
Effect.gen(function* () {
listedSkills = [effectSkill]
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const session = yield* sessions.create({ location })
const input = {
id: SessionMessage.ID.make("msg_skill_retry"),
sessionID: session.id,
text: "Apply this guidance",
skills: [{ id: Skill.ID.make("effect") }],
resume: false,
}
yield* sessions.prompt(input)
yield* SessionPending.promote(database.db, bus, session.id, "steer")
listedSkills = []
expect(yield* sessions.prompt(input)).toMatchObject({ id: input.id, data: { text: input.text } })
}).pipe(Effect.ensuring(Effect.sync(() => (listedSkills = [effectSkill])))),
)
it.effect("attaches a resolved skill snapshot to a normal prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
+1
View File
@@ -347,6 +347,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
model: Model.Ref.pipe(Schema.optional),
files: PromptInput.Prompt.fields.files,
agents: PromptInput.Prompt.fields.agents,
skills: PromptInput.Prompt.fields.skills,
delivery: SessionPending.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
+7
View File
@@ -341,6 +341,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
),
Effect.catchTag("Session.SkillAttachmentError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message, field: "skills" })),
),
),
}
}),
@@ -359,6 +362,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
model: ctx.payload.model,
files: ctx.payload.files,
agents: ctx.payload.agents,
skills: ctx.payload.skills,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
})
@@ -401,6 +405,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
),
Effect.catchTag("Session.SkillAttachmentError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message, field: "skills" })),
),
),
}
}),
@@ -54,6 +54,7 @@ export function Autocomplete(props: {
fileStyleId: number
agentStyleId: number
skillStyleId: number
hasSkill: (id: string) => boolean
promptPartTypeId: () => number
}) {
const editor = useEditorContext()
@@ -146,6 +147,7 @@ export function Autocomplete(props: {
| { type: "agent"; value: NonNullable<PromptInfo["agents"]>[number] }
| { type: "skill"; value: NonNullable<PromptInfo["skills"]>[number] },
) {
if (part.type === "skill" && props.hasSkill(part.value.id)) return
const input = props.input()
const currentCursorOffset = input.cursorOffset
@@ -1117,6 +1117,7 @@ export function Prompt(props: PromptProps) {
model,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
delivery,
})
.catch((error) => {
@@ -1724,6 +1725,7 @@ export function Prompt(props: PromptProps) {
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
skillStyleId={skillStyleId}
hasSkill={(id) => store.prompt.skills?.some((skill) => skill.id === id) ?? false}
promptPartTypeId={() => promptPartTypeId}
/>
</>
+4 -3
View File
@@ -373,12 +373,12 @@ const catalogEvents = new Set([
// briefly so the output commit renders inside it.
const SHELL_OUTPUT_GRACE_MS = 1500
function skillCommit(messageID: string, name: string): StreamCommit {
function skillCommit(messageID: string, name: string, skillID = messageID): StreamCommit {
return {
kind: "system",
source: "system",
messageID,
partID: `skill:${messageID}`,
partID: `skill:${skillID}`,
text: `→ Skill "${name}"`,
phase: "start",
}
@@ -653,7 +653,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!render) return
if (reuseVisibleWait && waiting) return
write([
...(message.skills ?? []).map((skill) => skillCommit(message.id + ":" + skill.id, skill.name)),
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
])
return
@@ -1666,6 +1666,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
model: selected,
files: attachments.files.length ? attachments.files : undefined,
agents: agents.length ? agents : undefined,
skills: skills.length ? skills : undefined,
delivery,
},
{ signal: next.signal },
@@ -2749,7 +2749,7 @@ describe("V2 mini transport", () => {
variant: undefined,
prompt: {
messageID: "msg_cmd",
text: "/deploy prod",
text: "/deploy prod /api-design",
parts: [
{
type: "file",
@@ -2757,6 +2757,11 @@ describe("V2 mini transport", () => {
filename: "mentioned.txt",
source: { type: "file", text: { start: 8, end: 12, value: "prod" } },
},
{
type: "skill",
id: "api-design",
source: { start: 13, end: 24, value: "/api-design" },
},
],
command: { name: "deploy", arguments: "prod" },
},
@@ -2779,6 +2784,7 @@ describe("V2 mini transport", () => {
mention: { start: 8, end: 12, text: "prod" },
},
],
skills: [{ id: "api-design", mention: { start: 13, end: 24, text: "/api-design" } }],
delivery: "steer",
})
// Selection rides the command payload; no separate client-side switch.