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 },