From 3c3244bef3fed4854f0a739ca7ac5f1f64db54e5 Mon Sep 17 00:00:00 2001 From: rlaope <105429536+rlaope@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:42:12 +0900 Subject: [PATCH 1/3] fix(coding-agent): preserve surviving effort on session resume --- .../scripts/scenarios/resume-effort-qa.mjs | 95 ++++++++ packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/core/changes.md | 18 ++ packages/coding-agent/src/core/sdk.ts | 21 +- .../test/suite/resume-effort-fixtures.ts | 58 +++++ .../test/suite/sdk-resume-effort.test.ts | 222 ++++++++++++++++++ 6 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs create mode 100644 packages/coding-agent/test/suite/resume-effort-fixtures.ts create mode 100644 packages/coding-agent/test/suite/sdk-resume-effort.test.ts diff --git a/.agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs b/.agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs new file mode 100644 index 000000000..304eb5f83 --- /dev/null +++ b/.agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs @@ -0,0 +1,95 @@ +// Run from the repository root: node --import tsx .agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs --self-test +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { writeResumeEffortFixture } from "../../../../../packages/coding-agent/test/suite/resume-effort-fixtures.ts"; +import { evidenceDir, guardRealAuth, makeSandbox, repoRoot } from "../lib/common.mjs"; +import { startFakeModelServer } from "../lib/fake-model-server.mjs"; +import { hermeticEnv, writeMockModelsJson } from "../lib/mock-loop-support.mjs"; +import { TargetRpcClient } from "../lib/target-rpc-client.mjs"; + +const slugIndex = process.argv.indexOf("--evidence"); +const evidence = evidenceDir(slugIndex < 0 ? "resume-effort" : process.argv[slugIndex + 1]); +const guard = guardRealAuth(); +const rows = []; +const scenarios = [ + { name: "orphan-recovery", expected: "xhigh", baseline: "xhigh", inline: ["xhigh"] }, + { name: "explicit-thinking", args: ["--thinking", "high"], expected: "high", baseline: "high", inline: ["xhigh", "high"] }, + // The shipped Astra map excludes off/minimal: an explicit off clamps to low. + { name: "explicit-off-clamped", args: ["--thinking", "off"], expected: "low", baseline: "low", inline: ["xhigh", "low"] }, + { name: "model-suffix", args: ["--model", "openai/gpt-6-astra:high"], expected: "high", baseline: "high", inline: ["xhigh", "high"] }, + { name: "intact-cache-baseline", intact: true, expected: "xhigh", baseline: "medium", inline: ["xhigh"] }, + { name: "intact-explicit-override", intact: true, args: ["--thinking", "high"], expected: "high", baseline: "medium", inline: ["xhigh", "high"] }, + { name: "later-selection", later: "high", expected: "high", baseline: "high", inline: ["xhigh", "high"] }, +]; + +for (const scenario of scenarios) { + const box = makeSandbox("resume-effort"); + const server = await startFakeModelServer({ turns: [{ text: "RESUME_EFFORT_QA_OK" }] }); + let client; + try { + writeMockModelsJson(box.agentDir, server, "openai-responses", { + id: "gpt-6-astra", reasoning: true, contextWindow: 600_000, maxTokens: 32_000, + }); + const settingsPath = join(box.agentDir, "settings.json"); + const settings = JSON.stringify({ + defaultProvider: "openai", defaultModel: "gpt-6-astra", defaultThinkingLevel: "minimal", + modelThinkingLevels: { "openai/gpt-6-astra": "low" }, + compaction: { enabled: false }, retry: { enabled: false }, + }); + writeFileSync(settingsPath, settings); + const manager = writeResumeEffortFixture(box.cwd, { provider: "openai", intact: scenario.intact }); + if (scenario.later) manager.appendThinkingLevelChange(scenario.later, { level: scenario.later, source: "explicit" }); + const sessionFile = manager.getSessionFile(); + assert.ok(sessionFile); + client = new TargetRpcClient({ + env: hermeticEnv(box.env), cwd: box.cwd, targetRoot: repoRoot(), + extraArgs: ["--session", sessionFile, "--no-extensions", "--no-skills", "--no-tools", ...(scenario.args ?? [])], + }); + const state = await client.send({ type: "get_state" }); + assert.equal(state.success, true); + assert.equal(state.data.thinkingLevel, scenario.expected); + assert.equal(state.data.model.id, "gpt-6-astra"); + assert.equal(state.data.sessionId, manager.getSessionId()); + // Subscribe before triggering the turn; no sleeps or polling. + const completed = client.waitFor((event) => event.message.type === "agent_end"); + const [ack] = await Promise.all([client.send({ type: "prompt", message: "SYNTHETIC_QA_PROMPT" }), completed]); + assert.equal(ack.success, true); + const reply = await client.send({ type: "get_last_assistant_text" }); + assert.equal(reply.data.text, "RESUME_EFFORT_QA_OK"); + const requests = server.requests.filter((request) => request.method === "POST"); + assert.equal(requests.length, 1); + const request = requests[0].body; + assert.equal(request.model, "gpt-6-astra"); + const inline = request.input.filter((item) => item.type === "configuration_update").map((item) => item.reasoning.effort); + assert.deepEqual(inline, scenario.inline); + assert.equal(request.reasoning?.effort, scenario.baseline); + await client.close(); + assert.equal(client.child.exitCode, 0); + if (!scenario.args) assert.equal(readFileSync(settingsPath, "utf8"), settings); + // Reopen the same on-disk history without overrides to prove durable precedence. + client = new TargetRpcClient({ + env: hermeticEnv(box.env), cwd: box.cwd, targetRoot: repoRoot(), + extraArgs: ["--session", sessionFile, "--no-extensions", "--no-skills", "--no-tools"], + }); + const reopened = await client.send({ type: "get_state" }); + assert.equal(reopened.success, true); + assert.equal(reopened.data.thinkingLevel, scenario.expected); + await client.close(); + assert.equal(client.child.exitCode, 0); + const row = { + name: scenario.name, pass: true, localEffort: state.data.thinkingLevel, + requestBaseline: request.reasoning?.effort, inlineEfforts: inline, + requests: requests.length, exitCode: client.child.exitCode, reopenedEffort: reopened.data.thinkingLevel, + }; + rows.push(row); + console.log(JSON.stringify(row)); + } finally { + if (client && client.child.exitCode === null) await client.close(); + await server.stop(); + box.cleanup(); + } +} +assert.equal(guard.assertUnchanged(), true); +writeFileSync(join(evidence, "resume-effort-qa.json"), `${JSON.stringify({ rows, realAuthUnchanged: true }, null, 2)}\n`); +console.log(`PASS: ${rows.length} real source CLI --session/RPC scenarios; evidence ${evidence}`); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4d77082fb..e4324da4f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,8 @@ ### Fixed +- Resuming a GPT-6 Astra session restores a supported surviving configuration effort when missing ancestry makes the original thinking selection unreachable, instead of replacing it with remembered startup defaults. Explicit overrides and later thinking selections still win, and the resumed inline configuration agrees with the selected effort. + - The ask-user overlay now uses plain Enter to confirm and advance through question tabs, keeps multi-select choices intact when confirming, provides an explicit Submit tab for optional comments and partial answers, prevents editor focus traps, and reports partial answers without requiring a comment ([#1573](https://github.com/code-yeongyu/senpi/issues/1573)) - Remote pi.dev catalog refreshes no longer lower or replace capabilities declared by Senpi's static model catalog; existing rows and `-fast` variants remain authoritative, conflicting provider/model fields are exposed through the remote-catalog diagnostics API, and malformed remote rows are rejected. diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index dc6b47a9e..37e614584 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-11 - Recover surviving session configuration effort on resume + +### What changed + +- `packages/coding-agent/src/core/sdk.ts`: restores supported reachable configuration effort before remembered defaults when no thinking selection survives. Uses the existing native GPT-6 Astra configuration scope and appends a final inline update when explicit or later selections disagree with historical configuration. + +### Why + +- Missing parent ancestry can make earlier thinking selections unreachable while leaving a session configuration update intact. Ignoring that update silently replaces the session effort with an unrelated startup default; retaining an older inline update can also override an explicit selection on the wire. + +### Why an extension could not handle it + +- `packages/coding-agent/src/core/sdk.ts` selects and persists resume effort before extension startup. The SDK must establish consistent local and inline state without repairing ancestry or changing global settings. + +### Expected merge conflict zones + +- `packages/coding-agent/src/core/sdk.ts`: initial thinking selection precedence and existing-session message restoration. Preserve the original reasoning baseline and append-only history semantics. + ## 2026-09-11 - Resolve branded changelog sources (senpi#1583) ### What changed diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9cbd2b693..8f31f152f 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -338,6 +338,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel = existingSession.thinkingLevel as ThinkingLevel; thinkingSelection = existingSession.thinkingSelection; } + // Match the native positional configuration-update scope in the Responses converter. + const hasApplicableConfiguration = + model?.reasoning && + model.id === "gpt-6-astra" && + (model.provider === "openai" || model.provider === "openai-codex") && + existingSession.configurationUpdate !== undefined; + if (thinkingLevel === undefined && hasExistingSession && model && hasApplicableConfiguration) { + // Missing ancestry can leave the configuration reachable but lose the original selection. + // Recover only supported levels, without inventing explicit-selection provenance. + thinkingLevel = getSupportedThinkingLevels(model).find( + (level) => level === existingSession.configurationUpdate?.effort, + ); + } if (thinkingLevel === undefined && model) { const remembered = settingsManager.getModelThinkingLevel(model.provider, model.id); if (remembered !== undefined) { @@ -502,9 +515,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Restore messages if session has existing data if (hasExistingSession) { agent.state.messages = existingSession.messages; - if (!hasThinkingEntry) { + if (!hasThinkingEntry || (hasApplicableConfiguration && thinkingLevel !== existingSession.thinkingLevel)) { sessionManager.appendThinkingLevelChange(thinkingLevel, thinkingSelection); } + if (hasApplicableConfiguration && existingSession.configurationUpdate?.effort !== thinkingLevel) { + // An explicit override or later thinking selection must also win over inline history. + // Append rather than rewrite the cache prefix or its original request baseline. + sessionManager.appendConfigurationUpdate(thinkingLevel); + agent.state.messages = sessionManager.buildSessionContext().messages; + } } else { // Save initial model and thinking level for new sessions so they can be restored on resume if (model) { diff --git a/packages/coding-agent/test/suite/resume-effort-fixtures.ts b/packages/coding-agent/test/suite/resume-effort-fixtures.ts new file mode 100644 index 000000000..5d5f3d2d2 --- /dev/null +++ b/packages/coding-agent/test/suite/resume-effort-fixtures.ts @@ -0,0 +1,58 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { type SessionEntry, type SessionHeader, SessionManager } from "../../src/core/session-manager.ts"; + +/** Synthetic history only: earlier selections exist on disk but may be unreachable. */ +export function writeResumeEffortFixture( + directory: string, + options: { provider?: string; modelId?: string; effort?: string; intact?: boolean } = {}, +): SessionManager { + const timestamp = "2026-09-01T00:00:00.000Z"; + const header: SessionHeader = { + type: "session", + version: 3, + id: "00000000-0000-4000-8000-000000000001", + timestamp, + cwd: directory, + }; + const entries: SessionEntry[] = [ + { type: "thinking_level_change", id: "baseline", parentId: null, timestamp, thinkingLevel: "medium" }, + { type: "thinking_level_change", id: "selection", parentId: "baseline", timestamp, thinkingLevel: "xhigh" }, + { + type: "thinking_level_change", + id: "other-branch", + parentId: "baseline", + timestamp, + thinkingLevel: "max", + }, + { + type: "message", + id: "reply", + parentId: options.intact ? "selection" : "missing-parent", + timestamp, + message: { + ...fauxAssistantMessage("SYNTHETIC_HISTORY", { timestamp: 1 }), + provider: options.provider ?? "openai-codex", + model: options.modelId ?? "gpt-6-astra", + }, + }, + { + type: "configuration_update", + id: "configuration", + parentId: "reply", + timestamp, + reasoning: { effort: options.effort ?? "xhigh" }, + }, + { + type: "message", + id: "prompt", + parentId: "configuration", + timestamp, + message: { role: "user", content: "SYNTHETIC_PROMPT", timestamp: 2 }, + }, + ]; + const path = join(directory, "resume-effort.jsonl"); + writeFileSync(path, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`); + return SessionManager.open(path); +} diff --git a/packages/coding-agent/test/suite/sdk-resume-effort.test.ts b/packages/coding-agent/test/suite/sdk-resume-effort.test.ts new file mode 100644 index 000000000..f3d42a743 --- /dev/null +++ b/packages/coding-agent/test/suite/sdk-resume-effort.test.ts @@ -0,0 +1,222 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentSession } from "../../src/core/agent-session.ts"; +import { type CreateAgentSessionOptions, createAgentSession } from "../../src/core/sdk.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { SettingsManager } from "../../src/core/settings-manager.ts"; +import { createTestResourceLoader } from "../utilities.ts"; +import { createHarness, type Harness } from "./harness.ts"; +import { writeResumeEffortFixture } from "./resume-effort-fixtures.ts"; + +const harnesses: Harness[] = []; +const sessions: AgentSession[] = []; +afterEach(() => { + for (const session of sessions.splice(0)) session.dispose(); + for (const harness of harnesses.splice(0)) harness.cleanup(); +}); + +async function fixture(options: Parameters[1] = {}) { + const provider = options.provider ?? "openai-codex"; + const modelId = options.modelId ?? "gpt-6-astra"; + const harness = await createHarness({ + provider, + models: [{ id: modelId, reasoning: true, contextWindow: 600_000, maxTokens: 32_000 }], + settings: { defaultThinkingLevel: "minimal", compaction: { enabled: false } }, + }); + harnesses.push(harness); + harness.settingsManager.setModelThinkingLevel(provider, modelId, "low"); + const manager = writeResumeEffortFixture(harness.tempDir, options); + const resume = async (overrides: Partial = {}) => { + const { session } = await createAgentSession({ + cwd: harness.tempDir, + agentDir: harness.tempDir, + authStorage: harness.authStorage, + modelRegistry: harness.modelRegistry, + settingsManager: harness.settingsManager, + sessionManager: manager, + resourceLoader: createTestResourceLoader(), + ...overrides, + }); + sessions.push(session); + return session; + }; + return { harness, manager, resume }; +} + +function inlineEfforts(session: AgentSession): string[] { + return session.messages.flatMap((message) => (message.role === "configurationUpdate" ? [message.effort] : [])); +} + +describe("SDK resume effort", () => { + it.each(["openai", "openai-codex"])( + "recovers surviving configuration before remembered defaults on %s", + async (provider) => { + // Given missing ancestry and an unrelated branch with a different selection. + const { harness, manager, resume } = await fixture({ provider }); + expect(manager.getBranch().some((entry) => entry.type === "thinking_level_change")).toBe(false); + expect(manager.buildSessionContext().thinkingLevel).toBe("off"); + + // When the real SDK resumes the persisted session. + const session = await resume(); + + // Then local state, durable recovery and inline effort agree, without changing defaults. + expect(session.thinkingLevel).toBe("xhigh"); + expect(inlineEfforts(session)).toEqual(["xhigh"]); + expect(manager.buildSessionContext().thinkingLevel).toBe("xhigh"); + expect(harness.settingsManager.getModelThinkingLevel(provider, "gpt-6-astra")).toBe("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("minimal"); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Missing persisted fixture"); + const reopened = SessionManager.open(sessionFile); + expect((await resume({ sessionManager: reopened })).thinkingLevel).toBe("xhigh"); + }, + ); + + it.each(["high", "off"] satisfies ThinkingLevel[])( + "honors explicit %s and reconciles inline effort", + async (thinkingLevel) => { + // Given orphaned history with xhigh in its inline configuration. + const { resume } = await fixture(); + // When a caller explicitly selects another level (the --thinking SDK seam). + const session = await resume({ thinkingLevel }); + // Then the final positional update does not override that choice on the wire. + expect(session.thinkingLevel).toBe(thinkingLevel); + expect(inlineEfforts(session)).toEqual(["xhigh", thinkingLevel]); + }, + ); + + it("honors a model-suffix selection instead of recovering configuration", async () => { + // Given the CLI's pre-resolved model suffix selection. + const { harness, resume } = await fixture(); + // When that explicit model/effort is passed to the SDK. + const session = await resume({ + model: harness.getModel(), + thinkingLevel: "high", + thinkingSelection: { level: "high", source: "explicit" }, + }); + // Then it wins both locally and in the inline stream. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session).at(-1)).toBe("high"); + }); + + it("persists an explicit override of intact configuration for subsequent resumes", async () => { + // Given an intact xhigh selection/configuration resumed with explicit high. + const { resume } = await fixture({ intact: true }); + await resume({ thinkingLevel: "high" }); + // When resumed again without CLI overrides. + const session = await resume(); + // Then the override remains the durable selection, not just a transient inline update. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session)).toEqual(["xhigh", "high"]); + expect(session.agent.state.reasoningBaseline).toBe("medium"); + }); + + it("preserves intact thinking history and its cache baseline", async () => { + // Given an intact medium -> xhigh branch and an unrelated max branch. + const { resume } = await fixture({ intact: true }); + // When resumed without overrides. + const session = await resume(); + // Then the branch selection and original baseline remain intact. + expect(session.thinkingLevel).toBe("xhigh"); + expect(session.agent.state.reasoningBaseline).toBe("medium"); + expect(inlineEfforts(session)).toEqual(["xhigh"]); + }); + + it("keeps a later genuine thinking selection ahead of older configuration", async () => { + // Given reachable high after the surviving xhigh configuration. + const { manager, resume } = await fixture(); + manager.appendThinkingLevelChange("high", { level: "high", source: "explicit" }); + // When resumed without overrides. + const session = await resume(); + // Then both local and effective inline state reflect the later selection. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session)).toEqual(["xhigh", "high"]); + }); + + it("recovers configuration before the global default when no per-model memory exists", async () => { + // Given global low with no remembered per-model level. + const { resume } = await fixture(); + const settingsManager = SettingsManager.inMemory({ defaultThinkingLevel: "low" }); + // When resumed. + const session = await resume({ settingsManager }); + // Then the session's surviving effort wins. + expect(session.thinkingLevel).toBe("xhigh"); + }); + + it("uses remembered defaults when neither selection nor configuration survives", async () => { + // Given the orphan branch before its configuration update. + const { manager, resume } = await fixture(); + manager.branch("reply"); + // When resumed. + const session = await resume(); + // Then the existing startup fallback remains unchanged. + expect(session.thinkingLevel).toBe("low"); + expect(inlineEfforts(session)).toEqual([]); + }); + + it("does not apply Astra configuration to an explicit different model", async () => { + // Given Astra history but an explicit replacement model without remembered effort. + const { harness, resume } = await fixture(); + const model = { ...harness.getModel(), id: "replacement-model" }; + // When the SDK receives the model override. + const session = await resume({ model }); + // Then its global default wins, not the prior model's configuration. + expect(session.model?.id).toBe("replacement-model"); + expect(session.thinkingLevel).toBe("minimal"); + }); + + it("does not recover configuration when reasoning is disabled on the model", async () => { + // Given an explicit model capability override. + const { harness, resume } = await fixture(); + // When the non-reasoning model resumes. + const session = await resume({ model: { ...harness.getModel(), reasoning: false } }); + // Then reasoning stays off. + expect(session.thinkingLevel).toBe("off"); + }); + + it("preserves a genuine effort change made after recovery on the next resume", async () => { + // Given a recovered session followed by a real session-only user selection. + const { resume } = await fixture(); + const recovered = await resume(); + recovered.setSessionThinkingLevel("high"); + // When the updated session resumes again. + const session = await resume(); + // Then the later selection wins locally and inline, with no duplicate update. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session)).toEqual(["xhigh", "high"]); + }); + + it.each(["invalid-effort", "", "MAX"])("does not recover invalid effort %j", async (effort) => { + // Given an unrecognized persisted effort string. + const { resume } = await fixture({ effort }); + // When resumed. + const session = await resume(); + // Then the remembered setting, not an invented/clamped recovery, wins. + expect(session.thinkingLevel).toBe("low"); + }); + + it("does not recover an effort excluded by the restored model's supported levels", async () => { + // Given a model override that does not support xhigh. + const { harness, resume } = await fixture(); + const model = { ...harness.getModel(), thinkingLevelMap: { low: "low", high: "high" } }; + // When that model is resumed. + const session = await resume({ model }); + // Then a supported remembered default wins instead of clamping xhigh to high. + expect(session.thinkingLevel).toBe("low"); + }); + + it.each([ + { provider: "faux", modelId: "gpt-6-astra" }, + { provider: "openai-codex", modelId: "gpt-5.6" }, + { provider: "openai", modelId: "gpt-6-astra-fast" }, + ])("ignores configuration outside the native model scope: $provider/$modelId", async (options) => { + // Given a model to which native configuration updates do not apply. + const { manager, resume } = await fixture(options); + const updatesBefore = manager.getEntries().filter((entry) => entry.type === "configuration_update"); + // When resumed. + const session = await resume(); + // Then defaults still apply and no model-inapplicable reconciliation is appended. + expect(session.thinkingLevel).toBe("low"); + expect(manager.getEntries().filter((entry) => entry.type === "configuration_update")).toEqual(updatesBefore); + }); +}); From 692d567cc1396d42fc8ec416667387d24658fe79 Mon Sep 17 00:00:00 2001 From: rlaope <105429536+rlaope@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:01:29 +0900 Subject: [PATCH 2/3] fix(test): align RPC worker routing with partial answers --- .../test/suite/rpc-worker-routing.test.ts | 121 ++++++++++-------- 1 file changed, 67 insertions(+), 54 deletions(-) diff --git a/packages/coding-agent/test/suite/rpc-worker-routing.test.ts b/packages/coding-agent/test/suite/rpc-worker-routing.test.ts index 5acc54f3d..008041458 100644 --- a/packages/coding-agent/test/suite/rpc-worker-routing.test.ts +++ b/packages/coding-agent/test/suite/rpc-worker-routing.test.ts @@ -151,62 +151,75 @@ it("starts real session workers under Node as well as Bun", async () => { } }, 60_000); -it("broadcasts question prompts across IPC and hydrates a late attachment", async () => { - const host = await startWorkerHost( - `export default function(pi) { +it.each(["answered", "comment-submitted"] as const)( + "broadcasts question prompts across IPC and hydrates a late attachment (%s)", + async (outcome) => { + const host = await startWorkerHost( + `export default function(pi) { pi.registerCommand("ask-question", {description: "question fixture", handler: async (_args, ctx) => { const result = await ctx.ui.question({requestId: "tool-question", waitForAnswer: true, timeoutMs: 60000, questions: ["q1", "q2"].map(id => ({id, header: id, question: id, options: [{label: "A"}, {label: "B"}], multiSelect: false}))}); ctx.ui.notify(JSON.stringify(result)); }}); }`, - { socket: true }, - ); - try { - const a = await host.connect(); - const b = await host.connect(); - const opened = await a.request({ type: "open_session", cwd: host.cwd, capabilities: ["question"] }); - const sessionId = opened.data?.sessionId; - await a.request({ type: "set_client_info", sessionId, capabilities: ["question"] }); - await b.request({ type: "open_session", cwd: host.cwd, sessionPath: opened.data?.state?.sessionFile }); - const qa = a.wait((r) => r.type === "extension_ui_request" && r.method === "question"); - const qb = b.wait((r) => r.type === "extension_ui_request" && r.method === "question"); - const prompt = a.request({ type: "prompt", sessionId, message: "/ask-question" }); - const frame = await qa; - expect((await qb).id).toBe(frame.id); - const c = await host.connect(); - const replay = c.wait((r) => r.type === "extension_ui_request" && r.method === "question"); - const attached = await c.request({ - type: "open_session", - cwd: host.cwd, - sessionPath: opened.data?.state?.sessionFile, - }); - expect(attached.data?.state?.pendingQuestions).toEqual([expect.objectContaining({ id: frame.id })]); - expect((await replay).id).toBe(frame.id); - const updated = a.wait((r) => r.type === "question_updated"); - b.send({ type: "extension_ui_progress", sessionId, id: frame.id, answers: { q1: { selected: ["A"] } } }); - expect((await updated).remainingMs).toBeGreaterThan(0); - const incomplete = b.wait((r) => r.error === "question_incomplete"); - b.send({ - type: "extension_ui_response", - sessionId, - id: frame.id, - answers: { q1: { selected: ["A"] } }, - comment: "", - }); - await incomplete; - const ra = a.wait((r) => r.type === "question_resolved"); - const rb = b.wait((r) => r.type === "question_resolved"); - b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {}, comment: "do it" }); - expect(await ra).toMatchObject({ outcome: "comment-submitted" }); - expect(await rb).toMatchObject({ outcome: "comment-submitted" }); - expect((await prompt).success).toBe(true); - const late = b.wait((r) => r.error === "question_already_resolved"); - b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {} }); - await late; - expect(c.records.filter((r) => r.method === "question")).toHaveLength(1); - const state = await c.request({ type: "get_state", sessionId }); - expect(state.data?.pendingQuestions).toEqual([]); - } finally { - await host.dispose(); - } -}, 60_000); + { socket: true }, + ); + try { + const a = await host.connect(); + const b = await host.connect(); + const opened = await a.request({ type: "open_session", cwd: host.cwd, capabilities: ["question"] }); + const sessionId = opened.data?.sessionId; + await a.request({ type: "set_client_info", sessionId, capabilities: ["question"] }); + await b.request({ type: "open_session", cwd: host.cwd, sessionPath: opened.data?.state?.sessionFile }); + const qa = a.wait((r) => r.type === "extension_ui_request" && r.method === "question"); + const qb = b.wait((r) => r.type === "extension_ui_request" && r.method === "question"); + const prompt = a.request({ type: "prompt", sessionId, message: "/ask-question" }); + const frame = await qa; + expect((await qb).id).toBe(frame.id); + const c = await host.connect(); + const replay = c.wait((r) => r.type === "extension_ui_request" && r.method === "question"); + const attached = await c.request({ + type: "open_session", + cwd: host.cwd, + sessionPath: opened.data?.state?.sessionFile, + }); + expect(attached.data?.state?.pendingQuestions).toEqual([expect.objectContaining({ id: frame.id })]); + expect((await replay).id).toBe(frame.id); + const updated = a.wait((r) => r.type === "question_updated"); + b.send({ type: "extension_ui_progress", sessionId, id: frame.id, answers: { q1: { selected: ["A"] } } }); + expect((await updated).remainingMs).toBeGreaterThan(0); + const incomplete = b.wait((r) => r.error === "question_incomplete"); + b.send({ + type: "extension_ui_response", + sessionId, + id: frame.id, + answers: {}, + comment: "", + }); + await incomplete; + const resolved = [a, b, c].map((peer) => + peer.wait((r) => r.type === "question_resolved" && r.id === frame.id), + ); + const answers = outcome === "answered" ? { q1: { selected: ["A"] } } : {}; + const comment = outcome === "answered" ? "" : "do it"; + b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers, comment }); + for (const record of await Promise.all(resolved)) { + expect(record).toMatchObject({ + outcome, + answers, + comment, + unanswered: outcome === "answered" ? ["q2"] : ["q1", "q2"], + }); + } + expect((await prompt).success).toBe(true); + const late = b.wait((r) => r.error === "question_already_resolved"); + b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {} }); + await late; + expect(c.records.filter((r) => r.method === "question")).toHaveLength(1); + const state = await c.request({ type: "get_state", sessionId }); + expect(state.data?.pendingQuestions).toEqual([]); + } finally { + await host.dispose(); + } + }, + 60_000, +); From db2648af40ae97a82eec10e6e6acf76b3f15be37 Mon Sep 17 00:00:00 2001 From: rlaope <105429536+rlaope@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:18:55 +0900 Subject: [PATCH 3/3] docs(coding-agent): attribute resume effort changelog to PR --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e4324da4f..de1334011 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,7 +18,7 @@ ### Fixed -- Resuming a GPT-6 Astra session restores a supported surviving configuration effort when missing ancestry makes the original thinking selection unreachable, instead of replacing it with remembered startup defaults. Explicit overrides and later thinking selections still win, and the resumed inline configuration agrees with the selected effort. +- Resuming a GPT-6 Astra session restores a supported surviving configuration effort when missing ancestry makes the original thinking selection unreachable, instead of replacing it with remembered startup defaults. Explicit overrides and later thinking selections still win, and the resumed inline configuration agrees with the selected effort ([#1596](https://github.com/code-yeongyu/senpi/pull/1596) by [@rlaope](https://github.com/rlaope)). - The ask-user overlay now uses plain Enter to confirm and advance through question tabs, keeps multi-select choices intact when confirming, provides an explicit Submit tab for optional comments and partial answers, prevents editor focus traps, and reports partial answers without requiring a comment ([#1573](https://github.com/code-yeongyu/senpi/issues/1573))