From fd716ec2f28f7e6a2c958cc5a60b37c0c29c34cd Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 26 Aug 2026 10:16:34 -0700 Subject: [PATCH] fix: allow casting runtime state writes Closes #1876 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/casting-runtime-state.md | 6 +++ packages/squad-sdk/src/tools/index.ts | 9 ++++- test/cli/state-mcp.test.ts | 53 +++++++++++++++++++++++++-- test/state-backend.test.ts | 24 ++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 .changeset/casting-runtime-state.md diff --git a/.changeset/casting-runtime-state.md b/.changeset/casting-runtime-state.md new file mode 100644 index 000000000..59383688d --- /dev/null +++ b/.changeset/casting-runtime-state.md @@ -0,0 +1,6 @@ +--- +'@bradygaster/squad-cli': patch +'@bradygaster/squad-sdk': patch +--- + +Allow the casting policy, registry, and history to be persisted through runtime state tools. diff --git a/packages/squad-sdk/src/tools/index.ts b/packages/squad-sdk/src/tools/index.ts index d815b41ad..817eccd9a 100644 --- a/packages/squad-sdk/src/tools/index.ts +++ b/packages/squad-sdk/src/tools/index.ts @@ -255,10 +255,17 @@ function normalizeStateToolDir(dir?: string): string { return normalized; } +const MUTABLE_CASTING_STATE_KEYS = new Set([ + 'casting/policy.json', + 'casting/registry.json', + 'casting/history.json', +]); + function validateMutableStateToolKey(key: string): void { const isMutable = key === 'decisions.md' || key.startsWith('decisions/inbox/') || + MUTABLE_CASTING_STATE_KEYS.has(key) || /^agents\/[a-zA-Z0-9_-]+\/history\.md$/.test(key) || key.startsWith('log/') || key.startsWith('orchestration-log/') || @@ -268,7 +275,7 @@ function validateMutableStateToolKey(key: string): void { if (!isMutable) { throw new Error( - 'State mutations are limited to mutable runtime state (decisions, inbox, logs, sessions, scratch files, agent history, and identity). Static config such as config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.', + 'State mutations are limited to mutable runtime state (decisions, inbox, casting policy/registry/history, logs, sessions, scratch files, agent history, and identity). Static config such as config.json, team.md, routing.md, charters, templates, and skills must not be changed with state tools.', ); } } diff --git a/test/cli/state-mcp.test.ts b/test/cli/state-mcp.test.ts index d3ee8053c..84db6868d 100644 --- a/test/cli/state-mcp.test.ts +++ b/test/cli/state-mcp.test.ts @@ -19,14 +19,14 @@ function git(args: string): string { return execSync(`git ${args}`, { cwd: TMP, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } -function initTwoLayerSquad(): void { +function initSquad(stateBackend: 'orphan' | 'two-layer'): void { mkdirSync(join(TMP, '.squad'), { recursive: true }); - writeFileSync(join(TMP, '.squad', 'config.json'), JSON.stringify({ stateBackend: 'two-layer' }, null, 2)); + writeFileSync(join(TMP, '.squad', 'config.json'), JSON.stringify({ stateBackend }, null, 2)); writeFileSync(join(TMP, 'README.md'), '# state mcp test\n'); git('init'); git('config user.email "test@test.com"'); git('config user.name "Test"'); - git('add .'); + git('add README.md .squad/config.json'); git('commit -m "init"'); } @@ -40,7 +40,6 @@ describe('state-mcp bridge', () => { beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); - initTwoLayerSquad(); }); afterEach(() => { @@ -49,6 +48,7 @@ describe('state-mcp bridge', () => { }); it('lists Squad state tools for MCP clients', async () => { + initSquad('two-layer'); const messages: JsonRpcMessage[] = []; const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage)); @@ -63,6 +63,7 @@ describe('state-mcp bridge', () => { }); it('writes and reads two-layer state without mutating the worktree .squad files', async () => { + initSquad('two-layer'); const messages: JsonRpcMessage[] = []; const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage)); @@ -92,4 +93,48 @@ describe('state-mcp bridge', () => { expect(existsSync(join(TMP, '.squad', 'decisions', 'inbox', 'mcp-proof.md'))).toBe(false); expect(readFileSync(join(TMP, '.squad', 'config.json'), 'utf8')).toContain('two-layer'); }); + + it.each(['orphan', 'two-layer'] as const)( + 'writes all casting runtime state keys through the %s backend', + async (stateBackend) => { + initSquad(stateBackend); + const messages: JsonRpcMessage[] = []; + const session = createStateMcpSession(TMP, message => messages.push(message as JsonRpcMessage)); + const castingState = [ + ['casting/policy.json', '{"mode":"auto"}\n'], + ['casting/registry.json', '{"agents":{}}\n'], + ['casting/history.json', '{"events":[]}\n'], + ] as const; + + for (const [key, content] of castingState) { + const writeIndex = messages.length; + await session.handleRequest({ + jsonrpc: '2.0', + id: `write-${key}`, + method: 'tools/call', + params: { + name: 'squad_state_write', + arguments: { key, content }, + }, + }); + expect(resultAsRecord(messages[writeIndex]!)['isError']).not.toBe(true); + + const readIndex = messages.length; + await session.handleRequest({ + jsonrpc: '2.0', + id: `read-${key}`, + method: 'tools/call', + params: { + name: 'squad_state_read', + arguments: { key }, + }, + }); + expect(resultAsRecord(messages[readIndex]!)['content']).toEqual([{ type: 'text', text: content }]); + expect(existsSync(join(TMP, '.squad', ...key.split('/')))).toBe(false); + } + + expect(git('status --porcelain')).toBe(''); + }, + 30_000, + ); }); diff --git a/test/state-backend.test.ts b/test/state-backend.test.ts index f43de8cfe..58b91335a 100644 --- a/test/state-backend.test.ts +++ b/test/state-backend.test.ts @@ -757,6 +757,30 @@ describe('ToolRegistry state tools with git-native backend', () => { expect(backend.read('sessions/session-1/state.md')).toBeUndefined(); }); + it('allows exactly the three casting runtime state keys', { timeout: 30_000 }, async () => { + const backend = new OrphanBranchBackend(TMP); + const adapter = new StateBackendStorageAdapter(backend, squadDir()); + const registry = new ToolRegistry(squadDir(), undefined, adapter); + const write = registry.getTool('squad_state_write')!; + + const castingKeys = [ + 'casting/policy.json', + 'casting/registry.json', + 'casting/history.json', + ]; + for (const key of castingKeys) { + await expect(write.handler({ key, content: '{}\n' })).resolves.toMatchObject({ resultType: 'success' }); + expect(backend.read(key)).toBe('{}\n'); + expect(existsSync(join(squadDir(), key))).toBe(false); + } + + await expect(write.handler({ key: 'casting/agents.json', content: '{}\n' })).resolves.toMatchObject({ resultType: 'failure' }); + await expect(write.handler({ key: 'casting/archive/history.json', content: '{}\n' })).resolves.toMatchObject({ resultType: 'failure' }); + expect(backend.read('casting/agents.json')).toBeUndefined(); + expect(backend.read('casting/archive/history.json')).toBeUndefined(); + expect(git('status --porcelain')).toBe(''); + }); + it('routes existing squad_decide writes through configured backend storage', { timeout: 20_000 }, async () => { const backend = new OrphanBranchBackend(TMP); const adapter = new StateBackendStorageAdapter(backend, squadDir());