switchroom 0.15.11 → 0.15.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +290 -181
- package/dist/host-control/main.js +5 -0
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +24 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +200 -6
- package/telegram-plugin/dist/server.js +23 -0
- package/telegram-plugin/gateway/gateway.ts +8 -0
- package/telegram-plugin/gateway/linear-activity.ts +160 -0
- package/telegram-plugin/gateway/model-command.ts +13 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +1 -1
- package/telegram-plugin/tests/linear-agent-activity.test.ts +124 -0
- package/telegram-plugin/tests/model-command.test.ts +40 -0
|
@@ -33,12 +33,20 @@ import {
|
|
|
33
33
|
} from '../../src/agents/model-picker.js'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Aliases the claude CLI resolves natively
|
|
37
|
-
* the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Aliases the claude CLI resolves natively (`claude --help`: "an alias for
|
|
37
|
+
* the latest model (e.g. 'fable', 'opus', or 'sonnet')"). Listed in help
|
|
38
|
+
* text only — the handler does NOT restrict to these (a full model id like
|
|
39
|
+
* `claude-opus-4-8` passes through and claude itself validates it, so new
|
|
40
|
+
* aliases/models work without a switchroom release).
|
|
41
|
+
*
|
|
42
|
+
* `fable` is the latest flagship (Fable 5) — kept selectable here on
|
|
43
|
+
* purpose. NB the alias is NOT the full codename: `claude-fable-5` (a
|
|
44
|
+
* pinned pre-launch id) was retired server-side and 4xx'd the whole fleet
|
|
45
|
+
* on 2026-06-13, while the `fable` alias keeps resolving to the current
|
|
46
|
+
* model. Aliases are the durable way to pick a model — see the model
|
|
47
|
+
* regression tests.
|
|
40
48
|
*/
|
|
41
|
-
export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'default'] as const
|
|
49
|
+
export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
|
|
42
50
|
|
|
43
51
|
/**
|
|
44
52
|
* Shape gate for the model argument. This string is typed literally
|
|
@@ -29,7 +29,7 @@ describe('request_secret — gateway wiring', () => {
|
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('is allow-listed and dispatched', () => {
|
|
32
|
-
expect(gw).toMatch(/'request_secret',\n
|
|
32
|
+
expect(gw).toMatch(/'request_secret',\n/)
|
|
33
33
|
expect(gw).toMatch(/case 'request_secret':\s*\n\s*return executeRequestSecret\(args\)/)
|
|
34
34
|
})
|
|
35
35
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import {
|
|
4
|
+
emitLinearAgentActivity,
|
|
5
|
+
type LinearTokenResult,
|
|
6
|
+
} from '../gateway/linear-activity.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tests for the `linear_agent_activity` MCP tool (#2298).
|
|
10
|
+
*
|
|
11
|
+
* Structural part: assert the tool is declared in bridge/bridge.ts and
|
|
12
|
+
* allow-listed + dispatched in gateway/gateway.ts (the gateway IIFE can't be
|
|
13
|
+
* imported in a test, so wiring is verified by reading the source — same
|
|
14
|
+
* constraint as gateway-request-secret.test.ts).
|
|
15
|
+
*
|
|
16
|
+
* Behavioural part: the activity-emit logic lives in gateway/linear-activity.ts
|
|
17
|
+
* with injectable token-resolver + fetch, so the happy path and the
|
|
18
|
+
* vault-denied path are exercised without a broker or the network.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const okToken = (token: string) => async (): Promise<LinearTokenResult> => ({ ok: true, token })
|
|
22
|
+
|
|
23
|
+
function fakeFetch(status: number, jsonBody: unknown): {
|
|
24
|
+
fetchImpl: typeof fetch
|
|
25
|
+
calls: Array<{ url: string; init?: RequestInit }>
|
|
26
|
+
} {
|
|
27
|
+
const calls: Array<{ url: string; init?: RequestInit }> = []
|
|
28
|
+
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
29
|
+
calls.push({ url, init })
|
|
30
|
+
return {
|
|
31
|
+
ok: status >= 200 && status < 300,
|
|
32
|
+
status,
|
|
33
|
+
json: async () => jsonBody,
|
|
34
|
+
text: async () => JSON.stringify(jsonBody),
|
|
35
|
+
} as unknown as Response
|
|
36
|
+
}) as unknown as typeof fetch
|
|
37
|
+
return { fetchImpl, calls }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('linear_agent_activity — gateway wiring (#2298)', () => {
|
|
41
|
+
const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
42
|
+
const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
|
|
43
|
+
|
|
44
|
+
it('declares the MCP tool with required {agent_session_id,type}', () => {
|
|
45
|
+
const idx = bridge.indexOf(`name: 'linear_agent_activity'`)
|
|
46
|
+
expect(idx).toBeGreaterThan(0)
|
|
47
|
+
const schema = bridge.slice(idx, idx + 2000)
|
|
48
|
+
expect(schema).toMatch(/required: \['agent_session_id', 'type'\]/)
|
|
49
|
+
expect(schema).toMatch(/thought/)
|
|
50
|
+
expect(schema).toMatch(/complete/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('is allow-listed and dispatched', () => {
|
|
54
|
+
expect(gw).toMatch(/'linear_agent_activity',\n\]\)/)
|
|
55
|
+
expect(gw).toMatch(/case 'linear_agent_activity':\s*\n\s*return executeLinearAgentActivity\(args\)/)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('emitLinearAgentActivity — behaviour (#2298)', () => {
|
|
60
|
+
it('POSTs an agentActivityCreate mutation on the happy path', async () => {
|
|
61
|
+
const { fetchImpl, calls } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
|
|
62
|
+
const r = await emitLinearAgentActivity(
|
|
63
|
+
{ agent_session_id: 'sess_1', type: 'thought', body: 'On it.' },
|
|
64
|
+
{ agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
65
|
+
)
|
|
66
|
+
expect(r.content[0].text).toMatch(/emitted on session sess_1/)
|
|
67
|
+
expect(calls).toHaveLength(1)
|
|
68
|
+
expect(calls[0].url).toBe('https://api.linear.app/graphql')
|
|
69
|
+
const sent = JSON.parse(calls[0].init!.body as string)
|
|
70
|
+
expect(sent.query).toMatch(/agentActivityCreate/)
|
|
71
|
+
expect(sent.variables.input.agentSessionId).toBe('sess_1')
|
|
72
|
+
expect(sent.variables.input.content).toEqual({ type: 'thought', body: 'On it.' })
|
|
73
|
+
expect((calls[0].init!.headers as Record<string, string>).Authorization).toBe('lin_tok')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('allows complete with no body', async () => {
|
|
77
|
+
const { fetchImpl } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
|
|
78
|
+
const r = await emitLinearAgentActivity(
|
|
79
|
+
{ agent_session_id: 'sess_2', type: 'complete' },
|
|
80
|
+
{ agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
81
|
+
)
|
|
82
|
+
expect(r.content[0].text).toMatch(/complete emitted/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('requires body for thought/message/error', async () => {
|
|
86
|
+
await expect(
|
|
87
|
+
emitLinearAgentActivity(
|
|
88
|
+
{ agent_session_id: 'sess_3', type: 'message' },
|
|
89
|
+
{ resolveToken: okToken('t'), log: () => {} },
|
|
90
|
+
),
|
|
91
|
+
).rejects.toThrow(/body is required/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects an unknown type', async () => {
|
|
95
|
+
await expect(
|
|
96
|
+
emitLinearAgentActivity(
|
|
97
|
+
{ agent_session_id: 'sess_4', type: 'banana', body: 'x' },
|
|
98
|
+
{ resolveToken: okToken('t'), log: () => {} },
|
|
99
|
+
),
|
|
100
|
+
).rejects.toThrow(/type must be one of/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('returns vault_request_access guidance when the token is denied', async () => {
|
|
104
|
+
const r = await emitLinearAgentActivity(
|
|
105
|
+
{ agent_session_id: 'sess_5', type: 'thought', body: 'hi' },
|
|
106
|
+
{
|
|
107
|
+
agent: 'carrie',
|
|
108
|
+
resolveToken: async () => ({ ok: false, reason: 'denied' }),
|
|
109
|
+
log: () => {},
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
expect(r.content[0].text).toMatch(/vault_request_access/)
|
|
113
|
+
expect(r.content[0].text).toMatch(/linear\/carrie\/token/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('surfaces a Linear API error status', async () => {
|
|
117
|
+
const { fetchImpl } = fakeFetch(401, { error: 'bad token' })
|
|
118
|
+
const r = await emitLinearAgentActivity(
|
|
119
|
+
{ agent_session_id: 'sess_6', type: 'thought', body: 'hi' },
|
|
120
|
+
{ agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
121
|
+
)
|
|
122
|
+
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -113,6 +113,46 @@ describe("isValidModelArg", () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
// Regression for the 2026-06-13 fleet outage: defaults.model was pinned to
|
|
117
|
+
// the full codename `claude-fable-5`, which Anthropic retired server-side →
|
|
118
|
+
// every agent 4xx'd. The fix is to select models by ALIAS (durable) instead
|
|
119
|
+
// of pinned ids. This locks in that `fable` (and the other aliases) stay
|
|
120
|
+
// selectable, and documents the alias-vs-codename distinction.
|
|
121
|
+
describe("model selection: aliases stay selectable (incl. fable)", () => {
|
|
122
|
+
it("lists fable as a first-class alias", () => {
|
|
123
|
+
// `fable` is the latest flagship (Fable 5) and must remain pickable.
|
|
124
|
+
expect(MODEL_ALIASES).toContain("fable");
|
|
125
|
+
// The standard set is intact alongside it.
|
|
126
|
+
for (const a of ["opus", "sonnet", "haiku", "default"]) {
|
|
127
|
+
expect(MODEL_ALIASES, a).toContain(a);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("each alias is a valid model arg and parses as a set", () => {
|
|
132
|
+
for (const alias of MODEL_ALIASES) {
|
|
133
|
+
expect(isValidModelArg(alias), alias).toBe(true);
|
|
134
|
+
expect(parseModelCommand(`/model ${alias}`)).toEqual({ kind: "set", model: alias });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("the help text surfaces the fable alias", async () => {
|
|
139
|
+
const reply = await handleModelCommand({ kind: "help" }, makeDeps());
|
|
140
|
+
expect(reply.text).toContain("fable");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("passthrough: a full id (incl. the retired claude-fable-5 codename) is shape-accepted, not allowlisted", () => {
|
|
144
|
+
// switchroom does NOT allowlist models — the SHAPE gate passes any
|
|
145
|
+
// well-formed id through to claude, which is the sole validator. So the
|
|
146
|
+
// retired `claude-fable-5` codename still parses here (it just 4xx's at
|
|
147
|
+
// claude); selection flexibility (any current/future model) is preserved.
|
|
148
|
+
expect(parseModelCommand("/model claude-fable-5")).toEqual({
|
|
149
|
+
kind: "set",
|
|
150
|
+
model: "claude-fable-5",
|
|
151
|
+
});
|
|
152
|
+
expect(isValidModelArg("claude-fable-5")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
116
156
|
describe("handleModelCommand — show / help never inject (picker-wedge guard)", () => {
|
|
117
157
|
it("show renders configured model + switch options without injecting", async () => {
|
|
118
158
|
const { deps, calls } = makeDeps();
|