diff --git a/.changeset/v1-replay-profile-bind.md b/.changeset/v1-replay-profile-bind.md new file mode 100644 index 00000000000..2818e4f0b18 --- /dev/null +++ b/.changeset/v1-replay-profile-bind.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix v1 replay ignoring v2 `profile.bind` records, which made sessions resumed from CLI-created wires lose their tool allowlist and send requests without `tools`. diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 76cdec45874..b70e9881535 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -514,6 +514,8 @@ export function projectContext( case 'tools.unregister_user_tool': case 'tools.set_active_tools': case 'tools.update_store': + case 'profile.bind': + case 'tools.reset_active_tools': case 'llm.tools_snapshot': case 'llm.request': case 'mcp.tools_discovered': diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 126ac3bdede..d59b239cf0f 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -83,6 +83,29 @@ export const WIRE_RENDERERS: RendererMap = { }, }, + 'profile.bind': { + tone: 'config', + label: 'profile', + headline: (r) => { + const parts: string[] = []; + if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`); + if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`); + if (r.thinkingEffort !== undefined) parts.push(`thinking=${r.thinkingEffort}`); + if (r.activeToolNames !== undefined) { + parts.push(`${r.activeToolNames.length} tools`); + } else { + parts.push('all tools'); + } + return { + main: ( + + {parts.length === 0 ? (no fields) : parts.join(' · ')} + + ), + }; + }, + }, + 'turn.prompt': { tone: 'turn', label: 'prompt', @@ -324,6 +347,14 @@ export const WIRE_RENDERERS: RendererMap = { }, }, + 'tools.reset_active_tools': { + tone: 'tools', + label: 'reset', + headline: () => ({ + main: all tools active, + }), + }, + 'tools.update_store': { tone: 'meta', label: 'store', diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 725186b098f..56960604295 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -45,6 +45,20 @@ export class ConfigState { } update(changed: AgentConfigUpdateData): void { + this.applyUpdate(changed, true); + } + + /** + * Restore config state without synthesizing a v1 replay record. This is + * used when a v2-only wire record is projected onto v1 state: the state + * should be available to the resumed agent, but the v2 record must not + * appear as a `config_updated` event in the replay surface. + */ + restore(changed: AgentConfigUpdateData): void { + this.applyUpdate(changed, false); + } + + private applyUpdate(changed: AgentConfigUpdateData, emitReplayRecord: boolean): void { if (Object.keys(changed).length === 0) return; const targetAlias = changed.modelAlias ?? this._modelAlias; @@ -86,10 +100,12 @@ export class ConfigState { type: 'config.update', ...effectiveChanged, }); - this.agent.replayBuilder.push({ - type: 'config_updated', - config: effectiveChanged, - }); + if (emitReplayRecord) { + this.agent.replayBuilder.push({ + type: 'config_updated', + config: effectiveChanged, + }); + } if (changed.cwd) { this._cwd = changed.cwd; this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd)); diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 29511a738a5..309c79edaf8 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -48,6 +48,34 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'config.update': agent.config.update(input); return; + case 'profile.bind': { + // v2-engine wires persist the profile binding (including the tool + // allowlist) via profile.bind instead of the v1 pair of config.update + + // tools.set_active_tools. Map it onto the v1 equivalents so a v2 + // session resumed here keeps its model, prompt, and tools. Records + // without an activeToolNames array (v2's "every tool active") are + // skipped wholesale: leaving the config untouched preserves the + // session-level fallback that applies the default profile when the + // replayed system prompt is empty, matching how names-less + // tools.set_active_tools records are treated. + if (!Array.isArray(input.activeToolNames)) return; + const thinkingEffort = input.thinkingEffort ?? input.thinkingLevel; + agent.config.restore({ + ...(input.modelAlias !== undefined ? { modelAlias: input.modelAlias } : {}), + ...(input.profileName !== undefined ? { profileName: input.profileName } : {}), + ...(thinkingEffort !== undefined ? { thinkingEffort } : {}), + ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}), + ...(input.subagents !== undefined ? { subagentNames: input.subagents } : {}), + }); + agent.tools.setActiveTools(input.activeToolNames, input.disallowedTools); + return; + } + case 'tools.reset_active_tools': + // v2-only transition back to the unrestricted default (every tool + // active). v1 keeps no "all tools" state to restore — the + // session-level profile fallback covers fresh resumes — so the record + // replays as a no-op. + return; case 'permission.set_mode': agent.permission.setMode(input.mode); return; diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index e9c1e1b240f..ac5ddb9b211 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -53,6 +53,33 @@ export interface AgentRecordEvents { 'config.update': AgentConfigUpdateData; + /** + * v2-engine profile binding (wire protocol 1.5). v1 never writes this + * record; the type exists so replay can map a v2 session's profile binding + * onto the v1 equivalents (`config.update` + `tools.set_active_tools`). + * Field shapes follow the v2 payload: live v2 records carry + * `thinkingEffort`, legacy ones may carry `thinkingLevel` instead. + */ + 'profile.bind': { + modelAlias?: string; + profileName?: string; + thinkingEffort?: string; + thinkingLevel?: string; + systemPrompt?: string; + /** v2 tool allowlist; absent means "every tool active". */ + activeToolNames?: readonly string[]; + /** v2 profile denylist, applied on top of `activeToolNames`. */ + disallowedTools?: readonly string[]; + subagents?: readonly string[]; + }; + + /** + * v2-engine transition back to the unrestricted default (every tool + * active). v1 has no corresponding state to rebuild; replay treats it as a + * no-op so the session-level profile fallback keeps its behavior. + */ + 'tools.reset_active_tools': {}; + 'permission.set_mode': { mode: PermissionMode; }; diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index 6bfc1a77136..ade82d96bed 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -285,6 +285,83 @@ describe('AgentRecords persistence metadata', () => { expect(names).not.toContain('Write'); }); + it('replays a v2 profile.bind record as config.update + tools.set_active_tools', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + // v2-engine wires are stamped with protocol 1.5. + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + { + type: 'profile.bind', + modelAlias: 'mock-model', + profileName: 'coding', + thinkingEffort: 'off', + systemPrompt: 'You are a v2 coding agent.', + activeToolNames: ['Read', 'Write', 'Bash'], + disallowedTools: ['Write'], + subagents: ['explore'], + } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + + await agent.records.replay(); + + expect(agent.config.modelAlias).toBe('mock-model'); + expect(agent.config.profileName).toBe('coding'); + expect(agent.config.systemPrompt).toBe('You are a v2 coding agent.'); + expect(agent.config.subagentNames).toEqual(['explore']); + expect(agent.replayBuilder.buildResult().map((record) => record.type)).not.toContain( + 'config_updated', + ); + const names = agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).toContain('Bash'); + expect(names).not.toContain('Write'); + }); + + it('skips a profile.bind record without activeToolNames so the profile fallback still fires', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + // v2's "every tool active" binding: no allowlist to restore. The record + // must be ignored wholesale so the session-level default-profile + // fallback (gated on an empty replayed system prompt) keeps firing. + { + type: 'profile.bind', + modelAlias: 'mock-model', + systemPrompt: 'You are a v2 agent.', + } as AgentRecord, + { type: 'goal.create', goalId: 'g1', objective: 'do work' } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + + await agent.records.replay(); + + expect(agent.config.systemPrompt).toBe(''); + // Replay continued past the skipped record. + expect(agent.goal.getGoal().goal?.goalId).toBe('g1'); + }); + + it('replays a v2 tools.reset_active_tools record as a no-op', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + { + type: 'tools.set_active_tools', + names: ['Read'], + } as AgentRecord, + { type: 'tools.reset_active_tools' } as AgentRecord, + { type: 'goal.create', goalId: 'g1', objective: 'do work' } as AgentRecord, + ]); + const { agent } = testAgent({ persistence }); + agent.config.update({ modelAlias: 'mock-model' }); + + await agent.records.replay(); + + // v1 has no "all tools" state to restore; the earlier restriction stays + // (fails closed) and replay continues past the record. + const names = agent.tools.loopTools.map((tool) => tool.name); + expect(names).toContain('Read'); + expect(names).not.toContain('Write'); + expect(agent.goal.getGoal().goal?.goalId).toBe('g1'); + }); + it('restores goal.* records during replay', async () => { const persistence = new InMemoryAgentRecordPersistence([ { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 },