kanna-code 0.3.0 → 0.4.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.
- package/dist/client/assets/index-5ura1eo0.js +419 -0
- package/dist/client/assets/index-B0Cwdy1-.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/server/agent.test.ts +170 -0
- package/src/server/agent.ts +32 -0
- package/src/server/cli-runtime.test.ts +180 -0
- package/src/server/cli-runtime.ts +274 -0
- package/src/server/cli.ts +20 -127
- package/src/server/codex-app-server.test.ts +186 -0
- package/src/server/codex-app-server.ts +17 -2
- package/src/server/discovery.ts +0 -39
- package/src/server/server.ts +3 -1
- package/src/server/ws-router.test.ts +47 -0
- package/src/server/ws-router.ts +4 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/tools.test.ts +12 -1
- package/src/shared/tools.ts +19 -1
- package/src/shared/types.ts +5 -1
- package/dist/client/assets/index-Byzgv_-q.js +0 -409
- package/dist/client/assets/index-gld9RxCU.css +0 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import process from "node:process"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } from "../shared/branding"
|
|
4
|
+
import { PROD_SERVER_PORT } from "../shared/ports"
|
|
5
|
+
|
|
6
|
+
export interface CliOptions {
|
|
7
|
+
port: number
|
|
8
|
+
openBrowser: boolean
|
|
9
|
+
strictPort: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StartedCli {
|
|
13
|
+
kind: "started"
|
|
14
|
+
stop: () => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExitedCli {
|
|
18
|
+
kind: "exited"
|
|
19
|
+
code: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CliRunResult = StartedCli | ExitedCli
|
|
23
|
+
|
|
24
|
+
export interface CliRuntimeDeps {
|
|
25
|
+
version: string
|
|
26
|
+
startServer: (options: CliOptions) => Promise<{ port: number; stop: () => Promise<void> }>
|
|
27
|
+
fetchLatestVersion: (packageName: string) => Promise<string>
|
|
28
|
+
installLatest: (packageName: string) => boolean
|
|
29
|
+
relaunch: (command: string, args: string[]) => number | null
|
|
30
|
+
openUrl: (url: string) => void
|
|
31
|
+
log: (message: string) => void
|
|
32
|
+
warn: (message: string) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ParsedArgs =
|
|
36
|
+
| { kind: "run"; options: CliOptions }
|
|
37
|
+
| { kind: "help" }
|
|
38
|
+
| { kind: "version" }
|
|
39
|
+
|
|
40
|
+
function printHelp() {
|
|
41
|
+
console.log(`${APP_NAME} — local-only project chat UI
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
${CLI_COMMAND} [options]
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--port <number> Port to listen on (default: ${PROD_SERVER_PORT})
|
|
48
|
+
--strict-port Fail instead of trying another port
|
|
49
|
+
--no-open Don't open browser automatically
|
|
50
|
+
--version Print version and exit
|
|
51
|
+
--help Show this help message`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseArgs(argv: string[]): ParsedArgs {
|
|
55
|
+
let port = PROD_SERVER_PORT
|
|
56
|
+
let openBrowser = true
|
|
57
|
+
let strictPort = false
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
60
|
+
const arg = argv[index]
|
|
61
|
+
if (arg === "--version" || arg === "-v") {
|
|
62
|
+
return { kind: "version" }
|
|
63
|
+
}
|
|
64
|
+
if (arg === "--help" || arg === "-h") {
|
|
65
|
+
return { kind: "help" }
|
|
66
|
+
}
|
|
67
|
+
if (arg === "--port") {
|
|
68
|
+
const next = argv[index + 1]
|
|
69
|
+
if (!next) throw new Error("Missing value for --port")
|
|
70
|
+
port = Number(next)
|
|
71
|
+
index += 1
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (arg === "--no-open") {
|
|
75
|
+
openBrowser = false
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (arg === "--strict-port") {
|
|
79
|
+
strictPort = true
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
kind: "run",
|
|
87
|
+
options: {
|
|
88
|
+
port,
|
|
89
|
+
openBrowser,
|
|
90
|
+
strictPort,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function compareVersions(currentVersion: string, latestVersion: string) {
|
|
96
|
+
const currentParts = normalizeVersion(currentVersion)
|
|
97
|
+
const latestParts = normalizeVersion(latestVersion)
|
|
98
|
+
const length = Math.max(currentParts.length, latestParts.length)
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index < length; index += 1) {
|
|
101
|
+
const current = currentParts[index] ?? 0
|
|
102
|
+
const latest = latestParts[index] ?? 0
|
|
103
|
+
if (current === latest) continue
|
|
104
|
+
return current < latest ? -1 : 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeVersion(version: string) {
|
|
111
|
+
return version
|
|
112
|
+
.trim()
|
|
113
|
+
.replace(/^v/i, "")
|
|
114
|
+
.split("-")[0]
|
|
115
|
+
.split(".")
|
|
116
|
+
.map((part) => Number.parseInt(part, 10))
|
|
117
|
+
.filter((part) => Number.isFinite(part))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function maybeSelfUpdate(argv: string[], deps: CliRuntimeDeps) {
|
|
121
|
+
deps.log(`${LOG_PREFIX} checking for updates`)
|
|
122
|
+
|
|
123
|
+
let latestVersion: string
|
|
124
|
+
try {
|
|
125
|
+
latestVersion = await deps.fetchLatestVersion(PACKAGE_NAME)
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
deps.warn(`${LOG_PREFIX} update check failed, continuing current version`)
|
|
129
|
+
if (error instanceof Error && error.message) {
|
|
130
|
+
deps.warn(`${LOG_PREFIX} ${error.message}`)
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!latestVersion || compareVersions(deps.version, latestVersion) >= 0) {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
deps.log(`${LOG_PREFIX} updating to ${latestVersion}`)
|
|
140
|
+
if (!deps.installLatest(PACKAGE_NAME)) {
|
|
141
|
+
deps.warn(`${LOG_PREFIX} update failed, continuing current version`)
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
deps.log(`${LOG_PREFIX} restarting into updated version`)
|
|
146
|
+
const exitCode = deps.relaunch(CLI_COMMAND, argv)
|
|
147
|
+
if (exitCode === null) {
|
|
148
|
+
deps.warn(`${LOG_PREFIX} restart failed, continuing current version`)
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return exitCode
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliRunResult> {
|
|
156
|
+
const parsedArgs = parseArgs(argv)
|
|
157
|
+
if (parsedArgs.kind === "version") {
|
|
158
|
+
deps.log(deps.version)
|
|
159
|
+
return { kind: "exited", code: 0 }
|
|
160
|
+
}
|
|
161
|
+
if (parsedArgs.kind === "help") {
|
|
162
|
+
printHelp()
|
|
163
|
+
return { kind: "exited", code: 0 }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const relaunchExitCode = await maybeSelfUpdate(argv, deps)
|
|
167
|
+
if (relaunchExitCode !== null) {
|
|
168
|
+
return { kind: "exited", code: relaunchExitCode }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { port, stop } = await deps.startServer(parsedArgs.options)
|
|
172
|
+
const url = `http://localhost:${port}`
|
|
173
|
+
const launchUrl = `${url}/projects`
|
|
174
|
+
|
|
175
|
+
deps.log(`${LOG_PREFIX} listening on ${url}`)
|
|
176
|
+
deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
|
|
177
|
+
|
|
178
|
+
if (parsedArgs.options.openBrowser) {
|
|
179
|
+
deps.openUrl(launchUrl)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
kind: "started",
|
|
184
|
+
stop,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function spawnDetached(command: string, args: string[]) {
|
|
189
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hasCommand(command: string) {
|
|
193
|
+
const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
|
|
194
|
+
return result.status === 0
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function canOpenMacApp(appName: string) {
|
|
198
|
+
const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
|
|
199
|
+
return result.status === 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function openUrl(url: string) {
|
|
203
|
+
const platform = process.platform
|
|
204
|
+
if (platform === "darwin") {
|
|
205
|
+
const appCandidates = [
|
|
206
|
+
"Google Chrome",
|
|
207
|
+
"Chromium",
|
|
208
|
+
"Brave Browser",
|
|
209
|
+
"Microsoft Edge",
|
|
210
|
+
"Arc",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
for (const appName of appCandidates) {
|
|
214
|
+
if (!canOpenMacApp(appName)) continue
|
|
215
|
+
spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
|
|
216
|
+
console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
spawnDetached("open", [url])
|
|
221
|
+
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (platform === "win32") {
|
|
225
|
+
const browserCommands = ["chrome", "msedge", "brave", "chromium"]
|
|
226
|
+
for (const command of browserCommands) {
|
|
227
|
+
if (!hasCommand(command)) continue
|
|
228
|
+
spawnDetached(command, [`--app=${url}`])
|
|
229
|
+
console.log(`${LOG_PREFIX} opened in app window via ${command}`)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
spawnDetached("cmd", ["/c", "start", "", url])
|
|
234
|
+
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
|
|
239
|
+
for (const command of browserCommands) {
|
|
240
|
+
if (!hasCommand(command)) continue
|
|
241
|
+
spawnDetached(command, [`--app=${url}`])
|
|
242
|
+
console.log(`${LOG_PREFIX} opened in app window via ${command}`)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
spawnDetached("xdg-open", [url])
|
|
247
|
+
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function fetchLatestPackageVersion(packageName: string) {
|
|
251
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`)
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
throw new Error(`registry returned ${response.status}`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const payload = await response.json() as { version?: unknown }
|
|
257
|
+
if (typeof payload.version !== "string" || !payload.version.trim()) {
|
|
258
|
+
throw new Error("registry response did not include a version")
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return payload.version
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function installLatestPackage(packageName: string) {
|
|
265
|
+
if (!hasCommand("bun")) return false
|
|
266
|
+
const result = spawnSync("bun", ["install", "-g", `${packageName}@latest`], { stdio: "inherit" })
|
|
267
|
+
return result.status === 0
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function relaunchCli(command: string, args: string[]) {
|
|
271
|
+
const result = spawnSync(command, args, { stdio: "inherit" })
|
|
272
|
+
if (result.error) return null
|
|
273
|
+
return result.status ?? 0
|
|
274
|
+
}
|
package/src/server/cli.ts
CHANGED
|
@@ -1,141 +1,34 @@
|
|
|
1
1
|
import process from "node:process"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
fetchLatestPackageVersion,
|
|
4
|
+
installLatestPackage,
|
|
5
|
+
openUrl,
|
|
6
|
+
relaunchCli,
|
|
7
|
+
runCli,
|
|
8
|
+
} from "./cli-runtime"
|
|
5
9
|
import { startKannaServer } from "./server"
|
|
6
10
|
|
|
7
11
|
// Read version from package.json at the package root
|
|
8
12
|
const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json()
|
|
9
13
|
const VERSION: string = pkg.version ?? "0.0.0"
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Options:
|
|
23
|
-
--port <number> Port to listen on (default: ${PROD_SERVER_PORT})
|
|
24
|
-
--no-open Don't open browser automatically
|
|
25
|
-
--version Print version and exit
|
|
26
|
-
--help Show this help message`)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function parseArgs(argv: string[]): CliOptions {
|
|
30
|
-
let port = PROD_SERVER_PORT
|
|
31
|
-
let openBrowser = true
|
|
32
|
-
|
|
33
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
34
|
-
const arg = argv[index]
|
|
35
|
-
if (arg === "--version" || arg === "-v") {
|
|
36
|
-
console.log(VERSION)
|
|
37
|
-
process.exit(0)
|
|
38
|
-
}
|
|
39
|
-
if (arg === "--help" || arg === "-h") {
|
|
40
|
-
printHelp()
|
|
41
|
-
process.exit(0)
|
|
42
|
-
}
|
|
43
|
-
if (arg === "--port") {
|
|
44
|
-
const next = argv[index + 1]
|
|
45
|
-
if (!next) throw new Error("Missing value for --port")
|
|
46
|
-
port = Number(next)
|
|
47
|
-
index += 1
|
|
48
|
-
continue
|
|
49
|
-
}
|
|
50
|
-
if (arg === "--no-open") {
|
|
51
|
-
openBrowser = false
|
|
52
|
-
continue
|
|
53
|
-
}
|
|
54
|
-
if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
port,
|
|
59
|
-
openBrowser,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function spawnDetached(command: string, args: string[]) {
|
|
64
|
-
spawn(command, args, { stdio: "ignore", detached: true }).unref()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function hasCommand(command: string) {
|
|
68
|
-
const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
|
|
69
|
-
return result.status === 0
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function canOpenMacApp(appName: string) {
|
|
73
|
-
const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
|
|
74
|
-
return result.status === 0
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function openUrl(url: string) {
|
|
78
|
-
const platform = process.platform
|
|
79
|
-
if (platform === "darwin") {
|
|
80
|
-
const appCandidates = [
|
|
81
|
-
"Google Chrome",
|
|
82
|
-
"Chromium",
|
|
83
|
-
"Brave Browser",
|
|
84
|
-
"Microsoft Edge",
|
|
85
|
-
"Arc",
|
|
86
|
-
]
|
|
87
|
-
|
|
88
|
-
for (const appName of appCandidates) {
|
|
89
|
-
if (!canOpenMacApp(appName)) continue
|
|
90
|
-
spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
|
|
91
|
-
console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
spawnDetached("open", [url])
|
|
96
|
-
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
if (platform === "win32") {
|
|
100
|
-
const browserCommands = ["chrome", "msedge", "brave", "chromium"]
|
|
101
|
-
for (const command of browserCommands) {
|
|
102
|
-
if (!hasCommand(command)) continue
|
|
103
|
-
spawnDetached(command, [`--app=${url}`])
|
|
104
|
-
console.log(`${LOG_PREFIX} opened in app window via ${command}`)
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
spawnDetached("cmd", ["/c", "start", "", url])
|
|
109
|
-
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
|
|
114
|
-
for (const command of browserCommands) {
|
|
115
|
-
if (!hasCommand(command)) continue
|
|
116
|
-
spawnDetached(command, [`--app=${url}`])
|
|
117
|
-
console.log(`${LOG_PREFIX} opened in app window via ${command}`)
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
spawnDetached("xdg-open", [url])
|
|
122
|
-
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const options = parseArgs(process.argv.slice(2))
|
|
126
|
-
const { port, stop } = await startKannaServer(options)
|
|
127
|
-
const url = `http://localhost:${port}`
|
|
128
|
-
const launchUrl = `${url}/projects`
|
|
129
|
-
|
|
130
|
-
console.log(`${LOG_PREFIX} listening on ${url}`)
|
|
131
|
-
console.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
|
|
15
|
+
const result = await runCli(process.argv.slice(2), {
|
|
16
|
+
version: VERSION,
|
|
17
|
+
startServer: startKannaServer,
|
|
18
|
+
fetchLatestVersion: fetchLatestPackageVersion,
|
|
19
|
+
installLatest: installLatestPackage,
|
|
20
|
+
relaunch: relaunchCli,
|
|
21
|
+
openUrl,
|
|
22
|
+
log: console.log,
|
|
23
|
+
warn: console.warn,
|
|
24
|
+
})
|
|
132
25
|
|
|
133
|
-
if (
|
|
134
|
-
|
|
26
|
+
if (result.kind === "exited") {
|
|
27
|
+
process.exit(result.code)
|
|
135
28
|
}
|
|
136
29
|
|
|
137
30
|
const shutdown = async () => {
|
|
138
|
-
await stop()
|
|
31
|
+
await result.stop()
|
|
139
32
|
process.exit(0)
|
|
140
33
|
}
|
|
141
34
|
|
|
@@ -1186,6 +1186,104 @@ describe("CodexAppServerManager", () => {
|
|
|
1186
1186
|
})
|
|
1187
1187
|
})
|
|
1188
1188
|
|
|
1189
|
+
test("infers multi-select Codex questions from prompt text and returns multiple answers", async () => {
|
|
1190
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1191
|
+
if (message.method === "initialize") {
|
|
1192
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1193
|
+
} else if (message.method === "thread/start") {
|
|
1194
|
+
child.writeServerMessage({
|
|
1195
|
+
id: message.id,
|
|
1196
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1197
|
+
})
|
|
1198
|
+
} else if (message.method === "turn/start") {
|
|
1199
|
+
child.writeServerMessage({
|
|
1200
|
+
id: message.id,
|
|
1201
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1202
|
+
})
|
|
1203
|
+
child.writeServerMessage({
|
|
1204
|
+
id: "req-1",
|
|
1205
|
+
method: "item/tool/requestUserInput",
|
|
1206
|
+
params: {
|
|
1207
|
+
threadId: "thread-1",
|
|
1208
|
+
turnId: "turn-1",
|
|
1209
|
+
itemId: "ask-1",
|
|
1210
|
+
questions: [
|
|
1211
|
+
{
|
|
1212
|
+
id: "runtimes",
|
|
1213
|
+
header: "Runtime",
|
|
1214
|
+
question: "Select all runtimes that apply",
|
|
1215
|
+
isOther: true,
|
|
1216
|
+
isSecret: false,
|
|
1217
|
+
options: [
|
|
1218
|
+
{ label: "bun", description: null },
|
|
1219
|
+
{ label: "node", description: null },
|
|
1220
|
+
],
|
|
1221
|
+
},
|
|
1222
|
+
],
|
|
1223
|
+
},
|
|
1224
|
+
})
|
|
1225
|
+
child.writeServerMessage({
|
|
1226
|
+
method: "turn/completed",
|
|
1227
|
+
params: {
|
|
1228
|
+
threadId: "thread-1",
|
|
1229
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1230
|
+
},
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
const manager = new CodexAppServerManager({
|
|
1236
|
+
spawnProcess: () => process as never,
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
await manager.startSession({
|
|
1240
|
+
chatId: "chat-1",
|
|
1241
|
+
cwd: "/tmp/project",
|
|
1242
|
+
model: "gpt-5.4",
|
|
1243
|
+
sessionToken: null,
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
const turn = await manager.startTurn({
|
|
1247
|
+
chatId: "chat-1",
|
|
1248
|
+
model: "gpt-5.4",
|
|
1249
|
+
content: "ask me",
|
|
1250
|
+
planMode: false,
|
|
1251
|
+
onToolRequest: async ({ tool }) => {
|
|
1252
|
+
expect(tool.toolKind).toBe("ask_user_question")
|
|
1253
|
+
if (tool.toolKind !== "ask_user_question") {
|
|
1254
|
+
return {}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
expect(tool.input.questions[0]?.multiSelect).toBe(true)
|
|
1258
|
+
|
|
1259
|
+
return {
|
|
1260
|
+
questions: [{
|
|
1261
|
+
id: "runtimes",
|
|
1262
|
+
question: "Select all runtimes that apply",
|
|
1263
|
+
multiSelect: true,
|
|
1264
|
+
}],
|
|
1265
|
+
answers: {
|
|
1266
|
+
runtimes: ["bun", "node"],
|
|
1267
|
+
},
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
await collectStream(turn.stream)
|
|
1273
|
+
|
|
1274
|
+
const response = process.messages.find((message: any) => message.id === "req-1")
|
|
1275
|
+
expect(response).toEqual({
|
|
1276
|
+
id: "req-1",
|
|
1277
|
+
result: {
|
|
1278
|
+
answers: {
|
|
1279
|
+
runtimes: {
|
|
1280
|
+
answers: ["bun", "node"],
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
})
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1189
1287
|
test("sends approval decisions back to the app-server", async () => {
|
|
1190
1288
|
const process = new FakeCodexProcess((message, child) => {
|
|
1191
1289
|
if (message.method === "initialize") {
|
|
@@ -1307,6 +1405,94 @@ describe("CodexAppServerManager", () => {
|
|
|
1307
1405
|
})
|
|
1308
1406
|
})
|
|
1309
1407
|
|
|
1408
|
+
test("interrupt clears a pending exit-plan wait so a new turn can start immediately", async () => {
|
|
1409
|
+
let resolveToolRequest!: (value: unknown) => void
|
|
1410
|
+
|
|
1411
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1412
|
+
if (message.method === "initialize") {
|
|
1413
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1414
|
+
} else if (message.method === "thread/start") {
|
|
1415
|
+
child.writeServerMessage({
|
|
1416
|
+
id: message.id,
|
|
1417
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1418
|
+
})
|
|
1419
|
+
} else if (message.method === "turn/start") {
|
|
1420
|
+
if (message.params.input[0]?.text === "make a plan") {
|
|
1421
|
+
child.writeServerMessage({
|
|
1422
|
+
id: message.id,
|
|
1423
|
+
result: { turn: { id: "turn-plan", status: "completed", error: null } },
|
|
1424
|
+
})
|
|
1425
|
+
child.writeServerMessage({
|
|
1426
|
+
method: "turn/plan/updated",
|
|
1427
|
+
params: {
|
|
1428
|
+
threadId: "thread-1",
|
|
1429
|
+
turnId: "turn-plan",
|
|
1430
|
+
explanation: "Plan the work",
|
|
1431
|
+
plan: [{ step: "Inspect repo", status: "completed" }],
|
|
1432
|
+
},
|
|
1433
|
+
})
|
|
1434
|
+
child.writeServerMessage({
|
|
1435
|
+
method: "turn/completed",
|
|
1436
|
+
params: {
|
|
1437
|
+
threadId: "thread-1",
|
|
1438
|
+
turn: { id: "turn-plan", status: "completed", error: null },
|
|
1439
|
+
},
|
|
1440
|
+
})
|
|
1441
|
+
} else {
|
|
1442
|
+
child.writeServerMessage({
|
|
1443
|
+
id: message.id,
|
|
1444
|
+
result: { turn: { id: "turn-next", status: "completed", error: null } },
|
|
1445
|
+
})
|
|
1446
|
+
child.writeServerMessage({
|
|
1447
|
+
method: "turn/completed",
|
|
1448
|
+
params: {
|
|
1449
|
+
threadId: "thread-1",
|
|
1450
|
+
turn: { id: "turn-next", status: "completed", error: null },
|
|
1451
|
+
},
|
|
1452
|
+
})
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
const manager = new CodexAppServerManager({
|
|
1458
|
+
spawnProcess: () => process as never,
|
|
1459
|
+
})
|
|
1460
|
+
|
|
1461
|
+
await manager.startSession({
|
|
1462
|
+
chatId: "chat-1",
|
|
1463
|
+
cwd: "/tmp/project",
|
|
1464
|
+
model: "gpt-5.4",
|
|
1465
|
+
sessionToken: null,
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
const turn = await manager.startTurn({
|
|
1469
|
+
chatId: "chat-1",
|
|
1470
|
+
model: "gpt-5.4",
|
|
1471
|
+
content: "make a plan",
|
|
1472
|
+
planMode: true,
|
|
1473
|
+
onToolRequest: async () => await new Promise((resolve) => {
|
|
1474
|
+
resolveToolRequest = resolve
|
|
1475
|
+
}),
|
|
1476
|
+
})
|
|
1477
|
+
|
|
1478
|
+
const iterator = turn.stream[Symbol.asyncIterator]()
|
|
1479
|
+
await iterator.next()
|
|
1480
|
+
await iterator.next()
|
|
1481
|
+
await iterator.next()
|
|
1482
|
+
await turn.interrupt()
|
|
1483
|
+
|
|
1484
|
+
const nextTurn = await manager.startTurn({
|
|
1485
|
+
chatId: "chat-1",
|
|
1486
|
+
model: "gpt-5.4",
|
|
1487
|
+
content: "continue",
|
|
1488
|
+
planMode: false,
|
|
1489
|
+
onToolRequest: async () => ({}),
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
await collectStream(nextTurn.stream)
|
|
1493
|
+
resolveToolRequest({})
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1310
1496
|
test("emits an error result when the app-server exits mid-turn", async () => {
|
|
1311
1497
|
const process = new FakeCodexProcess((message, child) => {
|
|
1312
1498
|
if (message.method === "initialize") {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type ThreadStartParams,
|
|
31
31
|
type ThreadStartResponse,
|
|
32
32
|
type ToolRequestUserInputParams,
|
|
33
|
+
type ToolRequestUserInputQuestion,
|
|
33
34
|
type ToolRequestUserInputResponse,
|
|
34
35
|
type TurnPlanStep,
|
|
35
36
|
type TurnPlanUpdatedNotification,
|
|
@@ -174,6 +175,13 @@ function isRecoverableResumeError(error: unknown): boolean {
|
|
|
174
175
|
)
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
const MULTI_SELECT_HINT_PATTERN = /\b(all that apply|select all|choose all|pick all|select multiple|choose multiple|pick multiple|multiple selections?|multiple choice|more than one|one or more)\b/i
|
|
179
|
+
|
|
180
|
+
function inferQuestionAllowsMultiple(question: ToolRequestUserInputQuestion): boolean {
|
|
181
|
+
const combinedText = [question.header, question.question].filter(Boolean).join(" ")
|
|
182
|
+
return MULTI_SELECT_HINT_PATTERN.test(combinedText)
|
|
183
|
+
}
|
|
184
|
+
|
|
177
185
|
function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQuestionItem[] {
|
|
178
186
|
return params.questions.map((question) => ({
|
|
179
187
|
id: question.id,
|
|
@@ -183,7 +191,7 @@ function toAskUserQuestionItems(params: ToolRequestUserInputParams): AskUserQues
|
|
|
183
191
|
label: option.label,
|
|
184
192
|
description: option.description ?? undefined,
|
|
185
193
|
})),
|
|
186
|
-
multiSelect:
|
|
194
|
+
multiSelect: inferQuestionAllowsMultiple(question),
|
|
187
195
|
}))
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -782,7 +790,14 @@ export class CodexAppServerManager {
|
|
|
782
790
|
stream: queue,
|
|
783
791
|
interrupt: async () => {
|
|
784
792
|
const pendingTurn = context.pendingTurn
|
|
785
|
-
if (!pendingTurn
|
|
793
|
+
if (!pendingTurn) return
|
|
794
|
+
|
|
795
|
+
context.pendingTurn = null
|
|
796
|
+
pendingTurn.resolved = true
|
|
797
|
+
pendingTurn.queue.finish()
|
|
798
|
+
|
|
799
|
+
if (!pendingTurn.turnId || !context.sessionToken) return
|
|
800
|
+
|
|
786
801
|
await this.sendRequest(context, "turn/interrupt", {
|
|
787
802
|
threadId: context.sessionToken,
|
|
788
803
|
turnId: pendingTurn.turnId,
|