diff --git a/.changeset/agent-core-mcp-hash-and-injection-compaction.md b/.changeset/agent-core-mcp-hash-and-injection-compaction.md new file mode 100644 index 000000000..85a28f89a --- /dev/null +++ b/.changeset/agent-core-mcp-hash-and-injection-compaction.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix two agent-core edge cases: long MCP tool names now always get an 8-char hex hash suffix (a signed-hash bug could emit a 9-char `-xxxxxxxx` suffix), and an injected system reminder that gets folded into a compaction summary is now cleared instead of being pinned to the summary message — so plugin session-start blocks re-inject and plan-mode reminders stay at full strength after compaction. diff --git a/packages/agent-core/src/agent/injection/injector.ts b/packages/agent-core/src/agent/injection/injector.ts index 504e412de..404403a23 100644 --- a/packages/agent-core/src/agent/injection/injector.ts +++ b/packages/agent-core/src/agent/injection/injector.ts @@ -11,8 +11,14 @@ export abstract class DynamicInjector { onContextCompacted(compactedCount: number): void { if (this.injectedAt !== null) { + // applyCompaction replaces the first `compactedCount` messages with a + // single summary at index 0, so a surviving injection (old index >= + // compactedCount) maps to new index >= 1. An injection that was inside + // the compacted prefix — including the last one (injectedAt === + // compactedCount - 1, which yields 0) — was folded into the summary and + // must become null rather than pointing at the summary itself. const newInjectedAt = this.injectedAt - compactedCount + 1; - this.injectedAt = newInjectedAt >= 0 ? newInjectedAt : null; + this.injectedAt = newInjectedAt >= 1 ? newInjectedAt : null; } } diff --git a/packages/agent-core/src/mcp/tool-naming.ts b/packages/agent-core/src/mcp/tool-naming.ts index 87dca29a5..749eed8bb 100644 --- a/packages/agent-core/src/mcp/tool-naming.ts +++ b/packages/agent-core/src/mcp/tool-naming.ts @@ -45,5 +45,9 @@ function stableHash8(input: string): string { hash ^= input.codePointAt(i)!; hash = Math.trunc(Math.imul(hash, 0x01000193)); } - return hash.toString(16).padStart(8, '0'); + // `Math.imul` yields a signed 32-bit int, so coerce to unsigned before + // hex-encoding — otherwise a negative hash renders as a 9-char `-xxxxxxxx` + // suffix (the `-` is not a hex digit), breaking the documented 8-char hash. + const unsigned = hash < 0 ? hash + 0x1_0000_0000 : hash; + return unsigned.toString(16).padStart(8, '0'); } diff --git a/packages/agent-core/test/agent/injection/manager.test.ts b/packages/agent-core/test/agent/injection/manager.test.ts index a8a91ea93..63f8b6297 100644 --- a/packages/agent-core/test/agent/injection/manager.test.ts +++ b/packages/agent-core/test/agent/injection/manager.test.ts @@ -37,6 +37,19 @@ class BoomInjector extends DynamicInjector { } } +class ProbeInjector extends DynamicInjector { + override readonly injectionVariant = 'probe_test'; + protected override getInjection(): string | undefined { + return undefined; + } + setInjectedAt(value: number | null): void { + (this as unknown as { injectedAt: number | null }).injectedAt = value; + } + getInjectedAt(): number | null { + return (this as unknown as { injectedAt: number | null }).injectedAt; + } +} + function installInjectors(manager: InjectionManager, injectors: DynamicInjector[]): void { (manager as unknown as { injectors: DynamicInjector[] }).injectors = injectors; } @@ -112,3 +125,28 @@ describe('InjectionManager registration', () => { expect(injectors.some((injector) => injector instanceof TodoListReminderInjector)).toBe(true); }); }); + +describe('DynamicInjector.onContextCompacted index remapping', () => { + it('remaps a surviving injection to its post-summary index', () => { + const ctx = testAgent(); + const probe = new ProbeInjector(ctx.agent); + probe.setInjectedAt(5); // old index 5 + probe.onContextCompacted(3); // first 3 messages folded into the summary at index 0 + expect(probe.getInjectedAt()).toBe(3); // 5 - 3 + 1 + }); + + it('nulls an injection folded into the summary, including the last compacted message', () => { + const ctx = testAgent(); + // Boundary: injectedAt === compactedCount - 1 was previously remapped to 0 + // (pointing at the summary) instead of null. + const last = new ProbeInjector(ctx.agent); + last.setInjectedAt(2); + last.onContextCompacted(3); // indices 0..2 compacted away + expect(last.getInjectedAt()).toBeNull(); + + const earlier = new ProbeInjector(ctx.agent); + earlier.setInjectedAt(0); + earlier.onContextCompacted(3); + expect(earlier.getInjectedAt()).toBeNull(); + }); +}); diff --git a/packages/agent-core/test/mcp/tool-naming.test.ts b/packages/agent-core/test/mcp/tool-naming.test.ts index 7c26b8855..f0b349536 100644 --- a/packages/agent-core/test/mcp/tool-naming.test.ts +++ b/packages/agent-core/test/mcp/tool-naming.test.ts @@ -43,6 +43,11 @@ describe('qualifyMcpToolName', () => { const name = qualifyMcpToolName(server, tool); expect(name.length).toBeLessThanOrEqual(64); expect(name.startsWith('mcp__')).toBe(true); + // The suffix must be a deterministic 8-char lowercase-hex hash. `Math.imul` + // yields a signed int, so a negative hash must not leak a `-` sign (which + // would also make the suffix 9 chars). These inputs hash negative. + const suffix = name.slice(name.lastIndexOf('_') + 1); + expect(suffix).toMatch(/^[0-9a-f]{8}$/); // Same input → same output (stable hash). expect(qualifyMcpToolName(server, tool)).toBe(name); });