diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 89882ea19b..eed26170b0 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -25,28 +25,26 @@ const render = (servers: ReadonlyArray) => ["", ...entries(servers), ""].join("\n") const update = (previous: ReadonlyArray, current: ReadonlyArray) => { - 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") } diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index 6544852df2..7fc4fc486c 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -29,28 +29,26 @@ const render = (references: ReadonlyArray) => ].join("\n") const update = (previous: ReadonlyArray, current: ReadonlyArray) => { - 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") } diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index abca17b6b0..a95a1ab0b8 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -31,28 +31,26 @@ const render = (skills: ReadonlyArray) => ].join("\n") const update = (previous: ReadonlyArray, current: ReadonlyArray) => { - 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") } diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index e9b04cb480..ce6aaae454 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -153,6 +153,32 @@ export function make(source: Source): 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( + previous: ReadonlyArray, + current: ReadonlyArray, + key: (value: A) => string, + changed: (previous: A, current: A) => boolean, +): { + readonly added: ReadonlyArray + readonly removed: ReadonlyArray + 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 { const sources = values.flatMap((value) => value[ContextTypeId]) diff --git a/packages/core/test/system-context/index.test.ts b/packages/core/test/system-context/index.test.ts index 492829c34d..f62053b088 100644 --- a/packages/core/test/system-context/index.test.ts +++ b/packages/core/test/system-context/index.test.ts @@ -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(() =>