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.
@@ -33,12 +33,20 @@ import {
33
33
  } from '../../src/agents/model-picker.js'
34
34
 
35
35
  /**
36
- * Aliases the claude CLI resolves natively. Listed in help text only
37
- * the handler does NOT restrict to these (a full model id like
38
- * `claude-opus-4-8` passes through and claude itself validates it, so
39
- * new aliases/models work without a switchroom release).
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();