@chatcode/cco-llm-chatcode-config 0.1.0

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/README.zh.md +133 -0
  4. package/cordis.patch.yml +4 -0
  5. package/cordis.web.patch.yml +12 -0
  6. package/docs/chatcode-login.md +88 -0
  7. package/docs/chatcode-login.zh.md +179 -0
  8. package/docs/chatcode-models.md +29 -0
  9. package/docs/chatcode-models.zh.md +29 -0
  10. package/docs/chatcode-reporting.md +96 -0
  11. package/docs/chatcode-reporting.zh.md +96 -0
  12. package/docs/decisions/2026-08-31-chatcode-model-source.md +39 -0
  13. package/docs/decisions/2026-08-31-chatcode-model-source.zh.md +39 -0
  14. package/docs/decisions/2026-09-16-actual-model-adapter-routing.md +31 -0
  15. package/docs/decisions/2026-09-16-actual-model-adapter-routing.zh.md +31 -0
  16. package/lib/client.js +469 -0
  17. package/lib/index.d.ts +263 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +4873 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/startup-gate-BaCbWaKH.js +164 -0
  22. package/lib/startup-gate-BaCbWaKH.js.map +1 -0
  23. package/lib/web-startup.d.ts +9 -0
  24. package/lib/web-startup.d.ts.map +1 -0
  25. package/lib/web-startup.js +20 -0
  26. package/lib/web-startup.js.map +1 -0
  27. package/package.json +121 -0
  28. package/vendor/README.md +7 -0
  29. package/vendor/dsh-llm-pi-ai/LICENSE +21 -0
  30. package/vendor/dsh-llm-pi-ai/README.i18n.yaml +6 -0
  31. package/vendor/dsh-llm-pi-ai/README.md +238 -0
  32. package/vendor/dsh-llm-pi-ai/README.zh.md +238 -0
  33. package/vendor/dsh-llm-pi-ai/package.json +65 -0
  34. package/vendor/dsh-llm-pi-ai/src/adapter.ts +434 -0
  35. package/vendor/dsh-llm-pi-ai/src/auth.ts +241 -0
  36. package/vendor/dsh-llm-pi-ai/src/catalog.ts +908 -0
  37. package/vendor/dsh-llm-pi-ai/src/config.ts +478 -0
  38. package/vendor/dsh-llm-pi-ai/src/context.ts +349 -0
  39. package/vendor/dsh-llm-pi-ai/src/discovery.ts +284 -0
  40. package/vendor/dsh-llm-pi-ai/src/index.ts +336 -0
  41. package/vendor/dsh-llm-pi-ai/src/invariant.ts +30 -0
  42. package/vendor/dsh-llm-pi-ai/src/login.ts +161 -0
  43. package/vendor/dsh-llm-pi-ai/src/provider.ts +192 -0
  44. package/vendor/dsh-llm-pi-ai/src/replay.ts +249 -0
  45. package/vendor/dsh-llm-pi-ai/src/stream.ts +232 -0
  46. package/vendor/dsh-llm-pi-ai/tests/adapter.e2e.ts +168 -0
  47. package/vendor/dsh-llm-pi-ai/tests/adapter.spec.ts +1034 -0
  48. package/vendor/dsh-llm-pi-ai/tests/assemble.ts +32 -0
  49. package/vendor/dsh-llm-pi-ai/tests/auth-double.ts +39 -0
  50. package/vendor/dsh-llm-pi-ai/tests/auth.spec.ts +221 -0
  51. package/vendor/dsh-llm-pi-ai/tests/catalog.spec.ts +1220 -0
  52. package/vendor/dsh-llm-pi-ai/tests/config.spec.ts +111 -0
  53. package/vendor/dsh-llm-pi-ai/tests/context.spec.ts +474 -0
  54. package/vendor/dsh-llm-pi-ai/tests/convert.spec.ts +922 -0
  55. package/vendor/dsh-llm-pi-ai/tests/discovery.spec.ts +374 -0
  56. package/vendor/dsh-llm-pi-ai/tests/dynamic-config.spec.ts +241 -0
  57. package/vendor/dsh-llm-pi-ai/tests/fixtures/qr-code.png +0 -0
  58. package/vendor/dsh-llm-pi-ai/tests/loader-composition.spec.ts +244 -0
  59. package/vendor/dsh-llm-pi-ai/tests/login.spec.ts +198 -0
  60. package/vendor/dsh-llm-pi-ai/tests/mock-server.ts +82 -0
  61. package/vendor/dsh-llm-pi-ai/tests/provider-apis.e2e.ts +266 -0
  62. package/vendor/dsh-llm-pi-ai/tests/sdk-options.spec.ts +106 -0
  63. package/vendor/dsh-llm-pi-ai/tsconfig.json +4 -0
  64. package/vendor/dsh-llm-pi-ai/tsconfig.upstream.json +51 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Real-composition guard for the dormant pi-ai posture: LlmRuntime,
3
+ * settings-file, credentials-local, and a bare `llm-pi-ai` row boot from a
4
+ * test-only cordis.yml through the actual Loader + Include path, an external
5
+ * edit of settings.yaml registers the route live, and the next request
6
+ * carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot
7
+ * catch Loader export-shape failures, which is why the twin adapter has the
8
+ * same guard.
9
+ */
10
+
11
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { pathToFileURL } from 'node:url'
15
+ import { afterEach, describe, expect, it, vi } from 'vitest'
16
+ import { Context } from '@deepseek-ai/cordis'
17
+ import Loader from '@deepseek-ai/cordis-plugin-loader'
18
+ import Include from '@deepseek-ai/cordis-plugin-include'
19
+ import LlmRuntime, { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
20
+ import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
21
+ import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
22
+ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
23
+ import { assemble } from './assemble.ts'
24
+ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
25
+
26
+ /** One text block, then a tool call truncated by the output-token ceiling. */
27
+ const truncatedToolCallEvents = [
28
+ '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
29
+ '{"choices":[{"delta":{"content":"partial"},"index":0,"finish_reason":null}]}',
30
+ '{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\\"text\\":"}}]},"index":0,"finish_reason":null}]}',
31
+ '{"choices":[{"delta":{},"index":0,"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":4}}',
32
+ '[DONE]',
33
+ ]
34
+
35
+ let root: string | undefined
36
+ let context: Context | undefined
37
+
38
+ afterEach(async () => {
39
+ await context?.fiber.dispose()
40
+ context = undefined
41
+ if (root !== undefined) await rm(root, { recursive: true, force: true })
42
+ root = undefined
43
+ await closeMockServers()
44
+ vi.unstubAllEnvs()
45
+ })
46
+
47
+ /** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */
48
+ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> {
49
+ root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
50
+ const settingsPath = join(root, 'settings.yaml')
51
+ await writeFile(settingsPath, '# personal settings\n')
52
+ await writeFile(join(root, '.credentials.yaml'), 'version: 1\nrefs:\n PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
53
+
54
+ const configPath = join(root, 'cordis.yml')
55
+ await writeFile(configPath, [
56
+ '- id: llm',
57
+ " name: 'test-llm-service'",
58
+ '- id: settings',
59
+ " name: '@deepseek-ai/dsh-settings-file'",
60
+ ' config:',
61
+ ` path: ${JSON.stringify(settingsPath)}`,
62
+ ' debounceMs: 10',
63
+ '- id: credentials',
64
+ " name: '@deepseek-ai/dsh-credentials-local'",
65
+ ' config:',
66
+ ` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`,
67
+ ' debounceMs: 10',
68
+ '- id: llm-pi-ai',
69
+ " name: '@deepseek-ai/dsh-llm-pi-ai'",
70
+ '',
71
+ ].join('\n'))
72
+
73
+ const ctx = new Context()
74
+ context = ctx
75
+ ctx.baseUrl = pathToFileURL(root).href + '/'
76
+ await ctx.plugin(Loader)
77
+ ctx.loader.builtins.include = Include
78
+ const modules = new Map<string, unknown>([
79
+ ['test-llm-service', LlmRuntime],
80
+ ['@deepseek-ai/dsh-settings-file', FileSettingsProvider],
81
+ ['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider],
82
+ ['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi],
83
+ ])
84
+ ctx.loader.internal = {
85
+ version: 'v2',
86
+ async import(specifier: string) {
87
+ if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
88
+ return modules.get(specifier)
89
+ },
90
+ } as unknown as NonNullable<typeof ctx.loader.internal>
91
+ await ctx.loader.create({
92
+ name: 'cordis:include',
93
+ config: { path: pathToFileURL(configPath).href },
94
+ })
95
+ await ctx.loader.await()
96
+ return { ctx, settingsPath }
97
+ }
98
+
99
+ describe('llm-pi-ai real dormant composition', () => {
100
+ it('boots with zero routes and registers one the moment settings supply a profile', async () => {
101
+ vi.stubEnv('PI_COMPOSITION_KEY', '')
102
+ const server = await mockServer([{ events: textEvents }])
103
+ const { ctx, settingsPath } = await loadComposition()
104
+
105
+ // The shipped posture: the adapter exists, no route does.
106
+ expect(ctx.llm.listProviders()).toEqual([])
107
+
108
+ // Exactly what the web Models page leaves on disk.
109
+ await writeFile(settingsPath, [
110
+ 'llm-pi-ai:',
111
+ ' providers:',
112
+ ' deepseek:',
113
+ ' apiKeyEnv: PI_COMPOSITION_KEY',
114
+ ` baseURL: ${server.url}`,
115
+ '',
116
+ ].join('\n'))
117
+ await vi.waitFor(() => {
118
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
119
+ }, { timeout: 5000 })
120
+
121
+ const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
122
+ expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
123
+ expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
124
+ })
125
+
126
+ it('continues natively after max-token assembly drops a tool call, with pruned replay metadata', async () => {
127
+ vi.stubEnv('PI_COMPOSITION_KEY', '')
128
+ const server = await mockServer([
129
+ { events: truncatedToolCallEvents },
130
+ { events: textEvents },
131
+ ])
132
+ const { ctx, settingsPath } = await loadComposition()
133
+ await writeFile(settingsPath, [
134
+ 'llm-pi-ai:',
135
+ ' providers:',
136
+ ' deepseek:',
137
+ ' apiKeyEnv: PI_COMPOSITION_KEY',
138
+ ` baseURL: ${server.url}`,
139
+ '',
140
+ ].join('\n'))
141
+ await vi.waitFor(() => {
142
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
143
+ }, { timeout: 5000 })
144
+
145
+ const truncated = await assemble(ctx, {
146
+ provider: 'deepseek',
147
+ model: 'deepseek-v4-flash',
148
+ messages: [],
149
+ })
150
+ expect(truncated.finish).toEqual({ kind: 'max-tokens' })
151
+ expect(truncated.message.content).toEqual([{ type: 'text', text: 'partial' }])
152
+ expect(truncated.message.source).toEqual({
153
+ kind: 'model',
154
+ provider: 'deepseek',
155
+ model: 'deepseek-v4-flash',
156
+ replayState: {
157
+ response: {
158
+ kind: 'pi-ai',
159
+ version: 2,
160
+ api: 'openai-completions',
161
+ provider: 'deepseek',
162
+ model: 'deepseek-v4-flash',
163
+ stopReason: 'length',
164
+ },
165
+ blocks: [{ type: 'text' }],
166
+ },
167
+ })
168
+
169
+ const continued = await assemble(ctx, {
170
+ provider: 'deepseek',
171
+ model: 'deepseek-v4-flash',
172
+ messages: [
173
+ truncated.message,
174
+ createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }),
175
+ ],
176
+ })
177
+ expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }])
178
+ expect(server.requests).toHaveLength(2)
179
+ expect(server.requests[1]).toMatchObject({
180
+ messages: [
181
+ { role: 'assistant', content: 'partial' },
182
+ { role: 'user', content: 'continue' },
183
+ ],
184
+ })
185
+ const followup = server.requests[1] as { messages?: unknown[] }
186
+ expect(followup.messages?.[0]).not.toHaveProperty('tool_calls')
187
+ })
188
+
189
+ it('continues a legacy session whose stored replay state no longer matches its content', async () => {
190
+ vi.stubEnv('PI_COMPOSITION_KEY', '')
191
+ const server = await mockServer([{ events: textEvents }])
192
+ const { ctx, settingsPath } = await loadComposition()
193
+ await writeFile(settingsPath, [
194
+ 'llm-pi-ai:',
195
+ ' providers:',
196
+ ' deepseek:',
197
+ ' apiKeyEnv: PI_COMPOSITION_KEY',
198
+ ` baseURL: ${server.url}`,
199
+ '',
200
+ ].join('\n'))
201
+ await vi.waitFor(() => {
202
+ expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
203
+ }, { timeout: 5000 })
204
+
205
+ // A pre-envelope session log entry: max-token assembly dropped the tool
206
+ // call from content while the flat v1 state still describes both blocks.
207
+ const poisoned = createMessage({
208
+ role: 'assistant',
209
+ content: [{ type: 'text', text: 'partial' }],
210
+ source: {
211
+ kind: 'model',
212
+ ...{
213
+ provider: 'deepseek',
214
+ model: 'deepseek-v4-flash',
215
+ replayState: {
216
+ kind: 'pi-ai',
217
+ version: 1,
218
+ api: 'openai-completions',
219
+ provider: 'deepseek',
220
+ model: 'deepseek-v4-flash',
221
+ stopReason: 'length',
222
+ blocks: [{ type: 'text' }, { type: 'tool-call' }],
223
+ },
224
+ },
225
+ },
226
+ })
227
+ const continued = await assemble(ctx, {
228
+ provider: 'deepseek',
229
+ model: 'deepseek-v4-flash',
230
+ messages: [
231
+ poisoned,
232
+ createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }),
233
+ ],
234
+ })
235
+ expect(continued.finish).toEqual({ kind: 'stop' })
236
+ expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }])
237
+ expect(server.requests[0]).toMatchObject({
238
+ messages: [
239
+ { role: 'assistant', content: 'partial' },
240
+ { role: 'user', content: 'continue' },
241
+ ],
242
+ })
243
+ })
244
+ })
@@ -0,0 +1,198 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { Context } from '@deepseek-ai/cordis'
6
+ import AuthorizationService from '@deepseek-ai/dsh-authorization'
7
+ import type { AuthorizationInteraction, AuthorizationNotice, AuthorizationPrompt } from '@deepseek-ai/dsh-authorization'
8
+ import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
9
+ import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
10
+ import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType, Credential } from '@earendil-works/pi-ai'
11
+
12
+ const login = vi.hoisted(() => vi.fn())
13
+
14
+ // The whole of what this module does with pi-ai is run one provider's login
15
+ // against a collection built with the harness store, so the collection is the
16
+ // boundary worth observing; a real login would open a browser.
17
+ vi.mock('@earendil-works/pi-ai', async importOriginal => ({
18
+ ...await importOriginal<typeof import('@earendil-works/pi-ai')>(),
19
+ createModels: () => ({ setProvider: () => {}, login }),
20
+ }))
21
+
22
+ const { credentialStoreFrom, authContextFrom, recordKeyFor } = await import('../src/auth.ts')
23
+ const { registerPiAiFlows } = await import('../src/login.ts')
24
+
25
+ const CODEX = recordKeyFor('openai-codex')
26
+ const dirs: string[] = []
27
+
28
+ /** A context with the record store, the seam, and every pi-ai login flow. */
29
+ async function harness(): Promise<Context> {
30
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-login-'))
31
+ dirs.push(dir)
32
+ const ctx = new Context()
33
+ await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
34
+ await ctx.plugin(AuthorizationService)
35
+ registerPiAiFlows(ctx, { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) })
36
+ return ctx
37
+ }
38
+
39
+ /** An interaction recording everything a flow says, answering every question. */
40
+ function surface(answer = 'typed'): AuthorizationInteraction & {
41
+ notices: AuthorizationNotice[]
42
+ prompts: AuthorizationPrompt[]
43
+ } {
44
+ const notices: AuthorizationNotice[] = []
45
+ const prompts: AuthorizationPrompt[] = []
46
+ return {
47
+ notices,
48
+ prompts,
49
+ notify: (notice) => { notices.push(notice) },
50
+ prompt: (prompt) => {
51
+ prompts.push(prompt)
52
+ return Promise.resolve(answer)
53
+ },
54
+ }
55
+ }
56
+
57
+ /** Drive one attempt, letting the mocked login talk back through `converse`. */
58
+ async function attempt(
59
+ ctx: Context,
60
+ converse: (interaction: AuthInteraction) => Promise<void>,
61
+ request: { key?: CredentialKey; method?: string } = {},
62
+ ): Promise<ReturnType<typeof surface>> {
63
+ const ui = surface()
64
+ login.mockImplementation(async (providerId: string, _type: AuthType, interaction: AuthInteraction) => {
65
+ await converse(interaction)
66
+ const granted: Credential = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }
67
+ await credentialStoreFrom(ctx).modify(providerId, () => Promise.resolve(granted))
68
+ return granted
69
+ })
70
+ await expect(ctx.authorization.begin({
71
+ key: request.key ?? CODEX,
72
+ interaction: ui,
73
+ ...request.method === undefined ? {} : { method: request.method },
74
+ })).resolves.toEqual({ status: 'authorized' })
75
+ return ui
76
+ }
77
+
78
+ afterEach(async () => {
79
+ login.mockReset()
80
+ await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
81
+ })
82
+
83
+ describe('pi-ai login flows', () => {
84
+ it('offers one flow per installed provider, with the methods that provider ships', async () => {
85
+ const ctx = await harness()
86
+ const offered = ctx.authorization.list()
87
+
88
+ // The OAuth-only provider is exactly the case this exists for: nothing
89
+ // else could ever configure it.
90
+ expect(offered.find(entry => entry.key === CODEX)?.methods)
91
+ .toEqual([{ id: 'oauth', label: expect.stringContaining('ChatGPT') as string }])
92
+ // A provider offering both keeps both, the subscription login first.
93
+ expect(offered.find(entry => entry.key === recordKeyFor('anthropic'))?.methods.map(one => one.id))
94
+ .toEqual(['oauth', 'api-key'])
95
+ // A key-only provider still gets a flow, because pi-ai collects the key
96
+ // through its own prompt rather than leaving it to the settings form.
97
+ expect(offered.find(entry => entry.key === recordKeyFor('deepseek'))?.methods.map(one => one.id))
98
+ .toEqual(['api-key'])
99
+ })
100
+
101
+ it('runs the pi-ai auth type the chosen method names', async () => {
102
+ const ctx = await harness()
103
+
104
+ await attempt(ctx, () => Promise.resolve())
105
+ expect(login).toHaveBeenLastCalledWith('openai-codex', 'oauth', expect.anything())
106
+
107
+ await attempt(ctx, () => Promise.resolve(), { key: recordKeyFor('anthropic'), method: 'api-key' })
108
+ expect(login).toHaveBeenLastCalledWith('anthropic', 'api_key', expect.anything())
109
+ })
110
+
111
+ it('commits what the login produced, where the adapter reads it back', async () => {
112
+ const ctx = await harness()
113
+
114
+ await attempt(ctx, () => Promise.resolve())
115
+
116
+ await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({
117
+ kind: 'grant',
118
+ payload: { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 },
119
+ })
120
+ })
121
+
122
+ it('restates every pi-ai login event in the neutral vocabulary', async () => {
123
+ const ctx = await harness()
124
+ const events: AuthEvent[] = [
125
+ { type: 'info', message: 'Read this first', links: [{ url: 'https://help.example' }] },
126
+ { type: 'info', message: 'Nothing to open' },
127
+ { type: 'auth_url', url: 'https://auth.example/start', instructions: 'Approve in the tab' },
128
+ { type: 'auth_url', url: 'https://auth.example/plain' },
129
+ { type: 'device_code', userCode: 'WXYZ-1234', verificationUri: 'https://device.example' },
130
+ { type: 'progress', message: 'Exchanging the code' },
131
+ // pi-ai's event union is open; an unrecognised member must still show
132
+ // the human that something is happening.
133
+ { type: 'quantum-handshake' } as unknown as AuthEvent,
134
+ ]
135
+
136
+ const ui = await attempt(ctx, (interaction) => {
137
+ for (const event of events) interaction.notify(event)
138
+ return Promise.resolve()
139
+ })
140
+
141
+ expect(ui.notices).toEqual([
142
+ { message: 'Read this first', url: 'https://help.example' },
143
+ { message: 'Nothing to open' },
144
+ { message: 'Approve in the tab', url: 'https://auth.example/start' },
145
+ { message: 'Open this page to continue signing in.', url: 'https://auth.example/plain' },
146
+ {
147
+ message: 'Enter this code on the verification page to finish signing in.',
148
+ url: 'https://device.example',
149
+ code: 'WXYZ-1234',
150
+ },
151
+ { message: 'Exchanging the code' },
152
+ { message: 'Signing in…' },
153
+ ])
154
+ })
155
+
156
+ it('restates every pi-ai prompt, carrying the per-prompt withdrawal signal', async () => {
157
+ const ctx = await harness()
158
+ const withdraw = new AbortController()
159
+ const prompts: AuthPrompt[] = [
160
+ { type: 'text', message: 'Your workspace', placeholder: 'acme' },
161
+ { type: 'secret', message: 'Paste the key' },
162
+ { type: 'secret', message: 'Paste the token', placeholder: 'sk-…' },
163
+ { type: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] },
164
+ // The manual-code question a browser callback can win the race against.
165
+ { type: 'manual_code', message: 'Paste the code', signal: withdraw.signal },
166
+ ]
167
+
168
+ const ui = await attempt(ctx, async (interaction) => {
169
+ for (const prompt of prompts) await interaction.prompt(prompt)
170
+ })
171
+
172
+ expect(ui.prompts).toEqual([
173
+ { kind: 'text', message: 'Your workspace', placeholder: 'acme' },
174
+ { kind: 'secret', message: 'Paste the key' },
175
+ { kind: 'secret', message: 'Paste the token', placeholder: 'sk-…' },
176
+ { kind: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] },
177
+ { kind: 'text', message: 'Paste the code', signal: withdraw.signal },
178
+ ])
179
+ })
180
+
181
+ it('hands the flow the attempt-wide cancellation signal', async () => {
182
+ const ctx = await harness()
183
+ let seen: AbortSignal | undefined
184
+ const controller = new AbortController()
185
+ login.mockImplementation((_id: string, _type: AuthType, interaction: AuthInteraction) => {
186
+ seen = interaction.signal
187
+ controller.abort()
188
+ return new Promise(() => {})
189
+ })
190
+
191
+ await expect(ctx.authorization.begin({
192
+ key: CODEX,
193
+ interaction: surface(),
194
+ signal: controller.signal,
195
+ })).resolves.toEqual({ status: 'cancelled' })
196
+ expect(seen?.aborted).toBe(true)
197
+ })
198
+ })
@@ -0,0 +1,82 @@
1
+ import { createServer } from 'node:http'
2
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
3
+
4
+ export interface MockServer {
5
+ url: string
6
+ paths: string[]
7
+ requests: unknown[]
8
+ headers: IncomingMessage['headers'][]
9
+ readonly closedResponses: number
10
+ responseClosed: Promise<void>
11
+ }
12
+
13
+ const servers: Server[] = []
14
+
15
+ /** Close every server opened since the last call; run from each spec's afterEach. */
16
+ export async function closeMockServers(): Promise<void> {
17
+ await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
18
+ }
19
+
20
+ /** A minimal complete text generation in pi-ai's chat-completions shape. */
21
+ export const textEvents = [
22
+ '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
23
+ '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
24
+ '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
25
+ '[DONE]',
26
+ ]
27
+
28
+ /** Local provider stand-in: replays scripted behaviors per request. */
29
+ export async function mockServer(script: {
30
+ status?: number
31
+ events?: string[]
32
+ body?: string
33
+ delayMs?: number
34
+ headers?: Record<string, string>
35
+ }[]): Promise<MockServer> {
36
+ const paths: string[] = []
37
+ const requests: unknown[] = []
38
+ const headers: IncomingMessage['headers'][] = []
39
+ let closedResponses = 0
40
+ const responseClosed = Promise.withResolvers<undefined>()
41
+ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
42
+ response.on('close', () => {
43
+ closedResponses += 1
44
+ responseClosed.resolve(undefined)
45
+ })
46
+ let body = ''
47
+ request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
48
+ request.on('end', () => {
49
+ paths.push(request.url ?? '')
50
+ requests.push(body.length === 0 ? undefined : JSON.parse(body))
51
+ headers.push(request.headers)
52
+ const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
53
+ if (behavior.status !== undefined && behavior.status !== 200) {
54
+ response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
55
+ response.end(behavior.body ?? '{}')
56
+ return
57
+ }
58
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
59
+ let index = 0
60
+ const writeNext = (): void => {
61
+ const event = behavior.events?.[index++]
62
+ if (event === undefined) { response.end(); return }
63
+ response.write(`data: ${event}\n\n`)
64
+ if (behavior.delayMs === undefined) writeNext()
65
+ else setTimeout(writeNext, behavior.delayMs)
66
+ }
67
+ writeNext()
68
+ })
69
+ })
70
+ servers.push(server)
71
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
72
+ const address = server.address()
73
+ if (address === null || typeof address === 'string') throw new Error('no port')
74
+ return {
75
+ url: `http://127.0.0.1:${address.port}`,
76
+ paths,
77
+ requests,
78
+ headers,
79
+ responseClosed: responseClosed.promise,
80
+ get closedResponses() { return closedResponses },
81
+ }
82
+ }