Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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 ([#1596](https://github.com/code-yeongyu/senpi/pull/1596) by [@rlaope](https://github.com/rlaope)).

### Removed

## [2026.9.12-2] - 2026-09-12
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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-12 - `app.question.answer` keybinding and `/answer` command for the async ask-user widget (senpi#1623)

### What changed
Expand Down
21 changes: 20 additions & 1 deletion packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions packages/coding-agent/test/suite/resume-effort-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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);
}
120 changes: 67 additions & 53 deletions packages/coding-agent/test/suite/rpc-worker-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,61 +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);
// A submission with neither an answer nor a comment carries no decision: it is
// rejected and the question stays pending for every attachment.
const incomplete = b.wait((r) => r.error === "question_incomplete");
b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {}, comment: "" });
await incomplete;
const ra = a.wait((r) => r.type === "question_resolved");
const rb = b.wait((r) => r.type === "question_resolved");
// A partial answer map is a decision on every surface (ask-user/pending.ts): it
// resolves the question as answered and reports the ids left unanswered.
b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: { q1: { selected: ["A"] } } });
const resolution = { outcome: "answered", answers: { q1: { selected: ["A"] } }, unanswered: ["q2"] };
expect(await ra).toMatchObject(resolution);
expect(await rb).toMatchObject(resolution);
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: {}, comment: "do it" });
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,
);
Loading