Skip to content

Commit 58aa637

Browse files
authored
feat(cli): add chat command (#6937)
* feat(cli): add chat command * fix(cli): harden chat command execution
1 parent cd0516c commit 58aa637

15 files changed

Lines changed: 1222 additions & 3 deletions

File tree

apps/docs/content/docs/en/cli/commands.mdx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,29 @@ sim configure [options]
113113
| `--unset <key...>` | No | Remove settings (endpoint, workspace, output). |
114114

115115
</CommandTable>
116+
117+
## Ask Sim and print the reply
118+
119+
```bash
120+
sim chat <message> [options]
121+
```
122+
123+
**Arguments**
124+
125+
<CommandTable>
126+
127+
| Argument | Required | Description |
128+
| --- | --- | --- |
129+
| `message` | Yes | What to ask Sim |
130+
131+
</CommandTable>
132+
133+
**Options**
134+
135+
<CommandTable>
136+
137+
| Option | Required | Description |
138+
| --- | --- | --- |
139+
| `-c, --conversation <id>` | No | Continue the conversation with this ID. |
140+
141+
</CommandTable>

apps/docs/content/docs/en/cli/reference.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,34 @@ sim configure [options]
101101

102102
</CommandTable>
103103

104+
## sim chat
105+
106+
Ask Sim and print the reply
107+
108+
```bash
109+
sim chat <message> [options]
110+
```
111+
112+
**Arguments**
113+
114+
<CommandTable>
115+
116+
| Argument | Required | Description |
117+
| --- | --- | --- |
118+
| `message` | Yes | What to ask Sim |
119+
120+
</CommandTable>
121+
122+
**Options**
123+
124+
<CommandTable>
125+
126+
| Option | Required | Description |
127+
| --- | --- | --- |
128+
| `-c, --conversation <id>` | No | Continue the conversation with this ID. |
129+
130+
</CommandTable>
131+
104132
## sim profiles
105133

106134
Also spelled `sim profile`.
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { createMockRequest } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const {
9+
MockV2ApiKeyUnauthenticatedError,
10+
MockWorkspaceAccessDeniedError,
11+
billingAttributionSnapshot,
12+
mockAssertActiveWorkspaceAccess,
13+
mockAuthenticateV2ApiKey,
14+
mockCheckOperationRate,
15+
mockCheckPreAuthRate,
16+
mockGenerateId,
17+
mockRequestExplicitStreamAbort,
18+
mockResolveBillingAttribution,
19+
mockRunHeadlessCopilotLifecycle,
20+
} = vi.hoisted(() => ({
21+
MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {},
22+
MockWorkspaceAccessDeniedError: class MockWorkspaceAccessDeniedError extends Error {},
23+
mockAssertActiveWorkspaceAccess: vi.fn(),
24+
mockAuthenticateV2ApiKey: vi.fn(),
25+
billingAttributionSnapshot: {
26+
actorUserId: 'user-1',
27+
workspaceId: 'workspace-1',
28+
organizationId: null,
29+
billedAccountUserId: 'user-1',
30+
billingEntity: { type: 'user', id: 'user-1' },
31+
billingPeriod: { start: '2026-08-01T00:00:00.000Z', end: '2026-09-01T00:00:00.000Z' },
32+
payerSubscription: null,
33+
},
34+
mockCheckOperationRate: vi.fn(),
35+
mockCheckPreAuthRate: vi.fn(),
36+
mockGenerateId: vi.fn(),
37+
mockResolveBillingAttribution: vi.fn(),
38+
mockRequestExplicitStreamAbort: vi.fn().mockResolvedValue(undefined),
39+
mockRunHeadlessCopilotLifecycle: vi.fn(),
40+
}))
41+
42+
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
43+
authenticateV2ApiKey: mockAuthenticateV2ApiKey,
44+
V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError,
45+
}))
46+
47+
vi.mock('@/lib/core/rate-limiter', () => ({
48+
getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }),
49+
RateLimiter: class RateLimiter {
50+
checkRateLimitDirect = mockCheckPreAuthRate
51+
checkRateLimitDirectOrThrow = mockCheckOperationRate
52+
},
53+
}))
54+
55+
vi.mock('@/app/api/v2/lib/gate', () => ({
56+
v2ApiGateError: vi.fn().mockResolvedValue(null),
57+
}))
58+
59+
vi.mock('@sim/utils/id', () => ({
60+
generateId: mockGenerateId,
61+
generateShortId: vi.fn(() => 'mock-short-id'),
62+
}))
63+
64+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
65+
assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess,
66+
isWorkspaceAccessDeniedError: (error: unknown) => error instanceof MockWorkspaceAccessDeniedError,
67+
}))
68+
69+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
70+
resolveBillingAttribution: mockResolveBillingAttribution,
71+
}))
72+
73+
vi.mock('@/lib/environment/utils', () => ({
74+
getPersonalAndWorkspaceEnv: vi.fn().mockResolvedValue({ personal: {}, workspace: {} }),
75+
}))
76+
77+
vi.mock('@/lib/copilot/environment-context', () => ({
78+
createCopilotEnvironmentContext: vi.fn().mockResolvedValue({ id: 'env-context' }),
79+
}))
80+
81+
vi.mock('@/lib/copilot/chat/workspace-context', () => ({
82+
generateWorkspaceContext: vi.fn().mockResolvedValue('workspace context'),
83+
}))
84+
85+
vi.mock('@/lib/copilot/chat/payload', () => ({
86+
buildIntegrationToolSchemas: vi.fn().mockResolvedValue([{ name: 'run_workflow' }]),
87+
}))
88+
89+
vi.mock('@/lib/copilot/entitlements', () => ({
90+
computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]),
91+
}))
92+
93+
vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({
94+
runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle,
95+
}))
96+
97+
vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({
98+
requestExplicitStreamAbort: mockRequestExplicitStreamAbort,
99+
}))
100+
101+
vi.mock('@/lib/copilot/secret-mount-policy', () => ({
102+
normalizeSecretMountPolicy: vi.fn(() => ({ secretScope: 'all', mountedSecrets: [] })),
103+
}))
104+
105+
vi.mock('@/lib/core/config/env-flags', () => ({
106+
isDocSandboxEnabled: false,
107+
}))
108+
109+
import { POST } from '@/app/api/v2/chat/route'
110+
111+
const personalAuth = {
112+
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
113+
rolloutUserId: 'user-1',
114+
rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'],
115+
rateLimitSubscription: null,
116+
keyType: 'personal',
117+
}
118+
119+
const successResult = {
120+
success: true,
121+
content: 'Hello there',
122+
toolCalls: [{ name: 'run_workflow' }, { name: 'internal_only' }],
123+
usage: { prompt: 10, completion: 5 },
124+
cost: { total: 0.01 },
125+
}
126+
127+
function callChat(body: Record<string, unknown>, headers: Record<string, string> = {}) {
128+
const req = createMockRequest('POST', body, { 'X-API-Key': 'test-key', ...headers })
129+
return POST(req, { params: Promise.resolve({}) })
130+
}
131+
132+
async function readNdjsonEvents(response: Response): Promise<Array<Record<string, unknown>>> {
133+
const raw = await response.text()
134+
return raw
135+
.split('\n')
136+
.filter((line) => line.trim().length > 0)
137+
.map((line) => JSON.parse(line))
138+
}
139+
140+
describe('POST /api/v2/chat', () => {
141+
beforeEach(() => {
142+
vi.clearAllMocks()
143+
let generated = 0
144+
mockGenerateId.mockImplementation(() => `generated-${++generated}`)
145+
mockAuthenticateV2ApiKey.mockResolvedValue(personalAuth)
146+
mockCheckPreAuthRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() })
147+
mockCheckOperationRate.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date() })
148+
mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' })
149+
mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot)
150+
mockRequestExplicitStreamAbort.mockResolvedValue(undefined)
151+
mockRunHeadlessCopilotLifecycle.mockResolvedValue(successResult)
152+
})
153+
154+
it('rejects a missing or invalid API key', async () => {
155+
mockAuthenticateV2ApiKey.mockRejectedValue(
156+
new MockV2ApiKeyUnauthenticatedError('API key required')
157+
)
158+
159+
const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' })
160+
161+
expect(response.status).toBe(401)
162+
})
163+
164+
it('rejects a workspace API key: chat has no acting user to attribute', async () => {
165+
mockAuthenticateV2ApiKey.mockResolvedValue({
166+
...personalAuth,
167+
principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-2' },
168+
keyType: 'workspace',
169+
})
170+
171+
const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' })
172+
173+
expect(response.status).toBe(403)
174+
const body = await response.json()
175+
expect(body.error.details.code).toBe('PRINCIPAL_KIND_NOT_PERMITTED')
176+
expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled()
177+
})
178+
179+
it('rejects an empty message before running anything', async () => {
180+
const response = await callChat({ workspaceId: 'workspace-1', message: '' })
181+
182+
expect(response.status).toBe(400)
183+
expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled()
184+
})
185+
186+
it('answers 403 when the caller cannot access the workspace', async () => {
187+
mockAssertActiveWorkspaceAccess.mockRejectedValue(new MockWorkspaceAccessDeniedError('denied'))
188+
189+
const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' })
190+
191+
expect(response.status).toBe(403)
192+
expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled()
193+
})
194+
195+
it('runs one turn and answers the reply with a generated conversation id', async () => {
196+
const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' })
197+
198+
expect(response.status).toBe(200)
199+
const body = await response.json()
200+
expect(body.data).toEqual({
201+
content: 'Hello there',
202+
model: 'mothership',
203+
conversationId: 'generated-1',
204+
tokens: { prompt: 10, completion: 5, total: 15 },
205+
cost: { total: 0.01 },
206+
toolCalls: [{ name: 'run_workflow' }],
207+
})
208+
209+
const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0]
210+
expect(payload).toMatchObject({
211+
messages: [{ role: 'user', content: 'hi' }],
212+
userId: 'user-1',
213+
workspaceId: 'workspace-1',
214+
chatId: 'generated-1',
215+
mode: 'agent',
216+
isHosted: true,
217+
workspaceContext: 'workspace context',
218+
integrationTools: [{ name: 'run_workflow' }],
219+
userPermission: 'admin',
220+
})
221+
expect(options).toMatchObject({
222+
userId: 'user-1',
223+
workspaceId: 'workspace-1',
224+
chatId: 'generated-1',
225+
goRoute: '/api/mothership/execute',
226+
autoExecuteTools: true,
227+
interactive: false,
228+
// Hosted execution refuses to run without attribution, so the resolved
229+
// snapshot must always ride along.
230+
billingAttribution: billingAttributionSnapshot,
231+
})
232+
expect(mockResolveBillingAttribution).toHaveBeenCalledWith({
233+
actorUserId: 'user-1',
234+
workspaceId: 'workspace-1',
235+
})
236+
})
237+
238+
it('continues the conversation the caller names', async () => {
239+
const response = await callChat({
240+
workspaceId: 'workspace-1',
241+
message: 'and then?',
242+
conversationId: 'conv-9',
243+
})
244+
245+
expect(response.status).toBe(200)
246+
const body = await response.json()
247+
expect(body.data.conversationId).toBe('conv-9')
248+
expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ chatId: 'conv-9' })
249+
})
250+
251+
it('answers a failed run as a 500 with the run error', async () => {
252+
mockRunHeadlessCopilotLifecycle.mockResolvedValue({ success: false, error: 'model exploded' })
253+
254+
const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' })
255+
256+
expect(response.status).toBe(500)
257+
const body = await response.json()
258+
expect(body.error.message).toBe('model exploded')
259+
})
260+
261+
it('streams heartbeats, chunks, and a final event for NDJSON callers', async () => {
262+
mockRunHeadlessCopilotLifecycle.mockImplementation(
263+
async (_payload: unknown, options: { onEvent?: (event: unknown) => Promise<void> }) => {
264+
await options.onEvent?.({
265+
type: 'text',
266+
payload: { channel: 'assistant', text: 'Hello' },
267+
})
268+
await options.onEvent?.({
269+
type: 'text',
270+
payload: { channel: 'assistant', text: 'Hello there' },
271+
})
272+
return successResult
273+
}
274+
)
275+
276+
const response = await callChat(
277+
{ workspaceId: 'workspace-1', message: 'hi' },
278+
{ accept: 'application/x-ndjson' }
279+
)
280+
281+
expect(response.status).toBe(200)
282+
expect(response.headers.get('content-type')).toContain('application/x-ndjson')
283+
const events = await readNdjsonEvents(response)
284+
285+
expect(events[0].type).toBe('heartbeat')
286+
const chunks = events.filter((event) => event.type === 'chunk')
287+
expect(chunks.map((chunk) => chunk.content)).toEqual(['Hello', ' there'])
288+
const final = events.at(-1) as { type: string; data: Record<string, unknown> }
289+
expect(final.type).toBe('final')
290+
expect(final.data).toMatchObject({ content: 'Hello there', conversationId: 'generated-1' })
291+
})
292+
293+
it('ends the NDJSON stream with an error event when the run fails', async () => {
294+
mockRunHeadlessCopilotLifecycle.mockResolvedValue({ success: false, error: 'model exploded' })
295+
296+
const response = await callChat(
297+
{ workspaceId: 'workspace-1', message: 'hi' },
298+
{ accept: 'application/x-ndjson' }
299+
)
300+
301+
expect(response.status).toBe(200)
302+
const events = await readNdjsonEvents(response)
303+
const last = events.at(-1) as { type: string; error?: string }
304+
expect(last.type).toBe('error')
305+
expect(last.error).toBe('model exploded')
306+
})
307+
})

0 commit comments

Comments
 (0)