From 5d86159ea8eed16fb795983ec979b53a93924e21 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 17 Mar 2026 13:47:07 -0400 Subject: [PATCH] fix: replan-slice infinite loop, non-standard finish_reason crash, fork-resilient test (#866) --- .../pi-ai/src/providers/openai-completions.ts | 11 +++-- .../extensions/gsd/tests/replan-slice.test.ts | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/pi-ai/src/providers/openai-completions.ts b/packages/pi-ai/src/providers/openai-completions.ts index 50ae643ca..7372d6880 100644 --- a/packages/pi-ai/src/providers/openai-completions.ts +++ b/packages/pi-ai/src/providers/openai-completions.ts @@ -747,10 +747,13 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto return "toolUse"; case "content_filter": return "error"; - default: { - const _exhaustive: never = reason; - throw new Error(`Unhandled stop reason: ${_exhaustive}`); - } + default: + // Third-party and community models (e.g. Qwen GGUF quants) may emit + // non-standard finish_reason values like "eos_token", "eos", or + // "end_of_turn". The OpenAI spec defines finish_reason as a string, + // so we treat unrecognized values as a normal stop rather than + // throwing — which would abort in-flight tool calls (#863). + return "stop"; } } diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts index d682a2b20..9d98afed0 100644 --- a/src/resources/extensions/gsd/tests/replan-slice.test.ts +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -493,4 +493,45 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue rmSync(base, { recursive: true, force: true }); } +// ═══════════════════════════════════════════════════════════════════════════ +// Artifact Resolution: resolveExpectedArtifactPath for replan-slice (#858) +// ═══════════════════════════════════════════════════════════════════════════ + +import { resolveExpectedArtifactPath, verifyExpectedArtifact } from '../auto-recovery.ts'; + +console.log('\n=== artifact: resolveExpectedArtifactPath returns REPLAN.md path for replan-slice ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + + const path = resolveExpectedArtifactPath('replan-slice', 'M001/S01', base); + assertTrue(path !== null, 'resolveExpectedArtifactPath returns non-null for replan-slice'); + assertTrue(path!.endsWith('S01-REPLAN.md'), 'path ends with S01-REPLAN.md'); + rmSync(base, { recursive: true, force: true }); +} + +console.log('\n=== artifact: verifyExpectedArtifact fails when REPLAN.md missing (#858) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + + const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base); + assertEq(result, false, 'verifyExpectedArtifact returns false when REPLAN.md is missing'); + rmSync(base, { recursive: true, force: true }); +} + +console.log('\n=== artifact: verifyExpectedArtifact passes when REPLAN.md exists (#858) ==='); +{ + const base = createFixtureBase(); + writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); + writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); + writeReplanFile(base, 'M001', 'S01', '# Replan\n\nBlocker addressed.'); + + const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base); + assertEq(result, true, 'verifyExpectedArtifact returns true when REPLAN.md exists'); + rmSync(base, { recursive: true, force: true }); +} + report();