Skip to content

Commit c4f580c

Browse files
committed
feat(cli): add chat command
1 parent 29acfb1 commit c4f580c

15 files changed

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

0 commit comments

Comments
 (0)