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")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const names = new Set(current.map((server) => server.server))
const previousByName = new Map(previous.map((server) => [server.server, server]))
const added = current.filter((server) => !previousByName.has(server.server))
const removed = previous.filter((server) => !names.has(server.server))
const changed = current.filter((server) => {
const before = previousByName.get(server.server)
return before !== undefined && before.instructions !== server.instructions
})
const diff = SystemContext.diffByKey(
previous,
current,
(server) => server.server,
(before, after) => before.instructions !== after.instructions,
)
// 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 [
"The available MCP server instructions have changed. This list supersedes the previous one.",
render(current),
].join("\n")
return [
...(added.length === 0
...(diff.added.length === 0
? []
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(added)]),
...(removed.length === 0
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
...(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")
}

View file

@ -29,28 +29,26 @@ const render = (references: ReadonlyArray<typeof Summary.Type>) =>
].join("\n")
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
const names = new Set(current.map((reference) => reference.name))
const previousByName = new Map(previous.map((reference) => [reference.name, reference]))
const added = current.filter((reference) => !previousByName.has(reference.name))
const removed = previous.filter((reference) => !names.has(reference.name))
const changed = current.filter((reference) => {
const before = previousByName.get(reference.name)
return before !== undefined && (before.path !== reference.path || before.description !== reference.description)
})
const diff = SystemContext.diffByKey(
previous,
current,
(reference) => reference.name,
(before, after) => before.path !== after.path || before.description !== after.description,
)
// 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 [
"The available project references have changed. This list supersedes the previous reference list.",
render(current),
].join("\n")
return [
...(added.length === 0
...(diff.added.length === 0
? []
: ["New project references are available in addition to those previously listed:", ...entries(added)]),
...(removed.length === 0
: ["New project references are available in addition to those previously listed:", ...entries(diff.added)]),
...(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")
}

View file

@ -31,28 +31,26 @@ const render = (skills: ReadonlyArray<Summary>) =>
].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const names = new Set(current.map((skill) => skill.name))
const previousByName = new Map(previous.map((skill) => [skill.name, skill]))
const added = current.filter((skill) => !previousByName.has(skill.name))
const removed = previous.filter((skill) => !names.has(skill.name))
const changed = current.filter((skill) => {
const before = previousByName.get(skill.name)
return before !== undefined && before.description !== skill.description
})
const diff = SystemContext.diffByKey(
previous,
current,
(skill) => skill.name,
(before, after) => before.description !== after.description,
)
// 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 [
"The available skills have changed. This list supersedes the previous available skills list.",
render(current),
].join("\n")
return [
...(added.length === 0
...(diff.added.length === 0
? []
: ["New skills are available in addition to those previously listed:", ...entries(added)]),
...(removed.length === 0
: ["New skills are available in addition to those previously listed:", ...entries(diff.added)]),
...(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")
}

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. */
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
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", () =>
Effect.sync(() => {
expect(() =>