Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/v1-replay-profile-bind.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions apps/vis/server/src/lib/context-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
31 changes: 31 additions & 0 deletions apps/vis/web/src/components/wire/renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<span className="truncate text-fg-0">
{parts.length === 0 ? <Dim>(no fields)</Dim> : parts.join(' · ')}
</span>
),
};
},
},

'turn.prompt': {
tone: 'turn',
label: 'prompt',
Expand Down Expand Up @@ -324,6 +347,14 @@ export const WIRE_RENDERERS: RendererMap = {
},
},

'tools.reset_active_tools': {
tone: 'tools',
label: 'reset',
headline: () => ({
main: <Mono>all tools active</Mono>,
}),
},

'tools.update_store': {
tone: 'meta',
label: 'store',
Expand Down
24 changes: 20 additions & 4 deletions packages/agent-core/src/agent/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-core/src/agent/records/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-core/src/agent/records/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
77 changes: 77 additions & 0 deletions packages/agent-core/test/agent/records/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading