refactor(core): extract SystemContext.diffByKey for delta renderers

Skill, reference, and MCP guidance shared the same keyed three-way diff
mechanics; each keeps only its own changed comparator and phrasing.
This commit is contained in:
Kit Langton 2026-07-02 09:10:29 -04:00
commit 3898cac63d
5 changed files with 92 additions and 39 deletions

View file

@ -25,28 +25,26 @@ const render = (servers: ReadonlyArray<Summary>) =>
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n") ["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => { const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const names = new Set(current.map((server) => server.server)) const diff = SystemContext.diffByKey(
const previousByName = new Map(previous.map((server) => [server.server, server])) previous,
const added = current.filter((server) => !previousByName.has(server.server)) current,
const removed = previous.filter((server) => !names.has(server.server)) (server) => server.server,
const changed = current.filter((server) => { (before, after) => before.instructions !== after.instructions,
const before = previousByName.get(server.server) )
return before !== undefined && before.instructions !== server.instructions
})
// Additions and removals render as small deltas; anything else restates the full list. // Additions and removals render as small deltas; anything else restates the full list.
if (changed.length > 0 || (added.length === 0 && removed.length === 0)) if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [ return [
"The available MCP server instructions have changed. This list supersedes the previous one.", "The available MCP server instructions have changed. This list supersedes the previous one.",
render(current), render(current),
].join("\n") ].join("\n")
return [ return [
...(added.length === 0 ...(diff.added.length === 0
? [] ? []
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(added)]), : ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
...(removed.length === 0 ...(diff.removed.length === 0
? [] ? []
: [ : [
`Instructions for the following MCP servers are no longer available: ${removed.map((server) => server.server).join(", ")}.`, `Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`,
]), ]),
].join("\n") ].join("\n")
} }

View file

@ -29,28 +29,26 @@ const render = (references: ReadonlyArray<typeof Summary.Type>) =>
].join("\n") ].join("\n")
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => { const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
const names = new Set(current.map((reference) => reference.name)) const diff = SystemContext.diffByKey(
const previousByName = new Map(previous.map((reference) => [reference.name, reference])) previous,
const added = current.filter((reference) => !previousByName.has(reference.name)) current,
const removed = previous.filter((reference) => !names.has(reference.name)) (reference) => reference.name,
const changed = current.filter((reference) => { (before, after) => before.path !== after.path || before.description !== after.description,
const before = previousByName.get(reference.name) )
return before !== undefined && (before.path !== reference.path || before.description !== reference.description)
})
// Additions and removals render as small deltas; anything else restates the full list. // Additions and removals render as small deltas; anything else restates the full list.
if (changed.length > 0 || (added.length === 0 && removed.length === 0)) if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [ return [
"The available project references have changed. This list supersedes the previous reference list.", "The available project references have changed. This list supersedes the previous reference list.",
render(current), render(current),
].join("\n") ].join("\n")
return [ return [
...(added.length === 0 ...(diff.added.length === 0
? [] ? []
: ["New project references are available in addition to those previously listed:", ...entries(added)]), : ["New project references are available in addition to those previously listed:", ...entries(diff.added)]),
...(removed.length === 0 ...(diff.removed.length === 0
? [] ? []
: [ : [
`The following project references are no longer available and must not be used: ${removed.map((reference) => reference.name).join(", ")}.`, `The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`,
]), ]),
].join("\n") ].join("\n")
} }

View file

@ -31,28 +31,26 @@ const render = (skills: ReadonlyArray<Summary>) =>
].join("\n") ].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => { const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const names = new Set(current.map((skill) => skill.name)) const diff = SystemContext.diffByKey(
const previousByName = new Map(previous.map((skill) => [skill.name, skill])) previous,
const added = current.filter((skill) => !previousByName.has(skill.name)) current,
const removed = previous.filter((skill) => !names.has(skill.name)) (skill) => skill.name,
const changed = current.filter((skill) => { (before, after) => before.description !== after.description,
const before = previousByName.get(skill.name) )
return before !== undefined && before.description !== skill.description
})
// Additions and removals render as small deltas; anything else restates the full list. // Additions and removals render as small deltas; anything else restates the full list.
if (changed.length > 0 || (added.length === 0 && removed.length === 0)) if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
return [ return [
"The available skills have changed. This list supersedes the previous available skills list.", "The available skills have changed. This list supersedes the previous available skills list.",
render(current), render(current),
].join("\n") ].join("\n")
return [ return [
...(added.length === 0 ...(diff.added.length === 0
? [] ? []
: ["New skills are available in addition to those previously listed:", ...entries(added)]), : ["New skills are available in addition to those previously listed:", ...entries(diff.added)]),
...(removed.length === 0 ...(diff.removed.length === 0
? [] ? []
: [ : [
`The following skills are no longer available and must not be used: ${removed.map((skill) => skill.name).join(", ")}.`, `The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
]), ]),
].join("\n") ].join("\n")
} }

View file

@ -153,6 +153,32 @@ export function make<A>(source: Source<A>): SystemContext {
]) ])
} }
/**
* Keyed three-way diff for list-shaped sources rendering delta updates.
* `changed` compares two values sharing a key; entries equal under it are dropped.
*/
export function diffByKey<A>(
previous: ReadonlyArray<A>,
current: ReadonlyArray<A>,
key: (value: A) => string,
changed: (previous: A, current: A) => boolean,
): {
readonly added: ReadonlyArray<A>
readonly removed: ReadonlyArray<A>
readonly changed: ReadonlyArray<{ readonly previous: A; readonly current: A }>
} {
const currentKeys = new Set(current.map(key))
const previousByKey = new Map(previous.map((value) => [key(value), value] as const))
return {
added: current.filter((value) => !previousByKey.has(key(value))),
removed: previous.filter((value) => !currentKeys.has(key(value))),
changed: current.flatMap((value) => {
const before = previousByKey.get(key(value))
return before === undefined || !changed(before, value) ? [] : [{ previous: before, current: value }]
}),
}
}
/** Combines contexts in order and rejects duplicate source keys immediately. */ /** Combines contexts in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<SystemContext>): SystemContext { export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
const sources = values.flatMap((value) => value[ContextTypeId]) const sources = values.flatMap((value) => value[ContextTypeId])

View file

@ -301,6 +301,39 @@ describe("SystemContext", () => {
}), }),
) )
it.effect("diffs list values by key with a changed comparator", () =>
Effect.sync(() => {
const previous = [
{ name: "effect", description: "Build with Effect" },
{ name: "debugging", description: "Diagnose bugs" },
{ name: "retired", description: "Old" },
]
const current = [
{ name: "effect", description: "Build with Effect v4" },
{ name: "debugging", description: "Diagnose bugs" },
{ name: "writing", description: "Write prose" },
]
expect(
SystemContext.diffByKey(
previous,
current,
(value) => value.name,
(before, after) => before.description !== after.description,
),
).toEqual({
added: [{ name: "writing", description: "Write prose" }],
removed: [{ name: "retired", description: "Old" }],
changed: [
{
previous: { name: "effect", description: "Build with Effect" },
current: { name: "effect", description: "Build with Effect v4" },
},
],
})
}),
)
it.effect("rejects duplicate source keys", () => it.effect("rejects duplicate source keys", () =>
Effect.sync(() => { Effect.sync(() => {
expect(() => expect(() =>