-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(companion): harden task path parsing and availability probes #574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0edc4e5
4c087f1
ce4ac23
790d49c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -535,8 +535,20 @@ function applyTurnNotification(state, message) { | |
| } | ||
| break; | ||
| case "error": | ||
| if (message.params?.willRetry) { | ||
| emitProgress( | ||
| state.onProgress, | ||
| `Codex error (retrying): ${message.params.error?.message ?? "unknown error"}`, | ||
| resolveErrorProgressPhase(message.params) | ||
| ); | ||
|
Comment on lines
+539
to
+543
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the app server emits an error with Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 790d49c: retriable app-server errors (\willRetry: true) now emit progress phase |
||
| break; | ||
| } | ||
| state.error = message.params.error; | ||
| emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed"); | ||
| emitProgress( | ||
| state.onProgress, | ||
| `Codex error: ${message.params.error.message}`, | ||
| resolveErrorProgressPhase(message.params) | ||
| ); | ||
| break; | ||
| case "turn/completed": | ||
| if ((message.params.threadId ?? null) !== state.threadId) { | ||
|
|
@@ -550,6 +562,9 @@ function applyTurnNotification(state, message) { | |
| "finalizing" | ||
| ); | ||
| completeTurn(state, message.params.turn); | ||
| if (message.params.turn.status === "completed") { | ||
| state.error = null; | ||
| } | ||
| break; | ||
| default: | ||
| break; | ||
|
|
@@ -751,7 +766,18 @@ async function resumeThread(client, threadId, cwd, options = {}) { | |
| return client.request("thread/resume", buildResumeParams(threadId, cwd, options)); | ||
| } | ||
|
|
||
| function buildResultStatus(turnState) { | ||
| export function shouldStoreTurnError(params = {}) { | ||
| return Boolean(params.error) && params.willRetry !== true; | ||
| } | ||
|
|
||
| export function resolveErrorProgressPhase(params = {}) { | ||
| return params.willRetry === true ? "retrying" : "failed"; | ||
| } | ||
|
|
||
| export function buildResultStatus(turnState) { | ||
| if (turnState.error) { | ||
| return 1; | ||
|
SomSamantray marked this conversation as resolved.
|
||
| } | ||
| return turnState.finalTurn?.status === "completed" ? 0 : 1; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
|
|
||
| import { parseArgs } from "../plugins/codex/scripts/lib/args.mjs"; | ||
|
|
||
| const TASK_CONFIG = { | ||
| valueOptions: ["model", "effort", "cwd", "prompt-file"], | ||
| booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], | ||
| aliasMap: { | ||
| m: "model", | ||
| C: "cwd" | ||
| } | ||
| }; | ||
|
|
||
| test("parseArgs with stopAtFirstPositional keeps prompt fragments out of options", () => { | ||
| const result = parseArgs(["--write", "review", "-m", "pytest"], { | ||
| ...TASK_CONFIG, | ||
| stopAtFirstPositional: true | ||
| }); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, undefined); | ||
| assert.equal(result.positionals.join(" "), "review -m pytest"); | ||
| }); | ||
|
|
||
| test("parseArgs without stopAtFirstPositional still consumes -m as model", () => { | ||
| const result = parseArgs(["--write", "review", "-m", "pytest"], TASK_CONFIG); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, "pytest"); | ||
| assert.equal(result.positionals.join(" "), "review"); | ||
| }); | ||
|
|
||
| test("parseArgs honors -- passthrough before stopAtFirstPositional matters", () => { | ||
| const result = parseArgs(["--write", "--", "-m", "pytest"], { | ||
| ...TASK_CONFIG, | ||
| stopAtFirstPositional: true | ||
| }); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, undefined); | ||
| assert.equal(result.positionals.join(" "), "-m pytest"); | ||
| }); | ||
|
|
||
| test("stopAtFirstPositional stops after unrecognized long option becomes positional", () => { | ||
| const result = parseArgs(["--write", "--coverage", "run", "-m", "pytest"], { | ||
| ...TASK_CONFIG, | ||
| stopAtFirstPositional: true | ||
| }); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, undefined); | ||
| assert.equal(result.positionals.join(" "), "--coverage run -m pytest"); | ||
| }); | ||
|
|
||
| test("without stopAtFirstPositional unrecognized long then -m still sets model", () => { | ||
| const result = parseArgs(["--write", "--coverage", "run", "-m", "pytest"], TASK_CONFIG); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, "pytest"); | ||
| assert.equal(result.positionals.join(" "), "--coverage run"); | ||
| }); | ||
|
|
||
| test("stopAtFirstPositional stops after unrecognized short option becomes positional", () => { | ||
| const result = parseArgs(["--write", "-z", "-m", "pytest"], { | ||
| ...TASK_CONFIG, | ||
| stopAtFirstPositional: true | ||
| }); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, undefined); | ||
| assert.equal(result.positionals.join(" "), "-z -m pytest"); | ||
| }); | ||
|
|
||
| test("stopAtFirstPositional still parses recognized flags before first positional", () => { | ||
| const result = parseArgs(["--write", "--model", "spark", "fix it"], { | ||
| ...TASK_CONFIG, | ||
| stopAtFirstPositional: true | ||
| }); | ||
|
|
||
| assert.equal(result.options.write, true); | ||
| assert.equal(result.options.model, "spark"); | ||
| assert.equal(result.positionals.join(" "), "fix it"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
|
|
||
| import { | ||
| buildResultStatus, | ||
| resolveErrorProgressPhase, | ||
| shouldStoreTurnError | ||
| } from "../plugins/codex/scripts/lib/codex.mjs"; | ||
|
|
||
| test("resolveErrorProgressPhase uses retrying for willRetry errors", () => { | ||
| assert.equal( | ||
| resolveErrorProgressPhase({ | ||
| willRetry: true, | ||
| error: { message: "x" } | ||
| }), | ||
| "retrying" | ||
| ); | ||
| assert.equal(resolveErrorProgressPhase({ error: { message: "x" } }), "failed"); | ||
| assert.equal(resolveErrorProgressPhase({}), "failed"); | ||
| }); | ||
|
|
||
| test("shouldStoreTurnError ignores retriable app-server errors", () => { | ||
| assert.equal( | ||
| shouldStoreTurnError({ | ||
| error: { message: "transient" }, | ||
| willRetry: true | ||
| }), | ||
| false | ||
| ); | ||
| assert.equal( | ||
| shouldStoreTurnError({ | ||
| error: { message: "terminal" } | ||
| }), | ||
| true | ||
| ); | ||
| }); | ||
|
|
||
| test("buildResultStatus fails when turnState.error is set despite completed finalTurn", () => { | ||
| const status = buildResultStatus({ | ||
| error: { message: "boom" }, | ||
| finalTurn: { id: "turn_1", status: "completed" } | ||
| }); | ||
|
|
||
| assert.equal(status, 1); | ||
| }); | ||
|
|
||
| test("buildResultStatus succeeds for completed turn without error", () => { | ||
| const status = buildResultStatus({ | ||
| error: null, | ||
| finalTurn: { id: "turn_1", status: "completed" } | ||
| }); | ||
|
|
||
| assert.equal(status, 0); | ||
| }); | ||
|
|
||
| test("buildResultStatus fails for non-completed finalTurn", () => { | ||
| const status = buildResultStatus({ | ||
| error: null, | ||
| finalTurn: { id: "turn_1", status: "failed" } | ||
| }); | ||
|
|
||
| assert.equal(status, 1); | ||
| }); | ||
|
|
||
| test("buildResultStatus fails when only error is present", () => { | ||
| const status = buildResultStatus({ | ||
| error: { message: "x" }, | ||
| finalTurn: null | ||
| }); | ||
|
|
||
| assert.equal(status, 1); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a raw task prompt starts with an unrecognized dash-prefixed token, such as
--coverage run -m pytest, that token is added topositionalswithout enablingstopOptionsbecause this new block only handles non-dash tokens. The later-m pytestis consequently still consumed as the model, so the prompt-parsing failure this option is intended to prevent remains for option-looking prompt prefixes. SetstopOptionswhenever an unrecognized option is treated as positional as well.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 790d49c: when \stopAtFirstPositional\ is set, unrecognized long/short options that fall through as positionals now arm \stopOptions, so later tokens like -m\ stay in the prompt instead of being parsed as --model. Covered in \ ests/args.test.mjs.