Skip to content

Commit cc0b86d

Browse files
t3dotggclaude
andauthored
fix(server): goals stop the running turn, clear while working, no echoes (#25426)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 3e47c1c commit cc0b86d

11 files changed

Lines changed: 913 additions & 91 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14524,6 +14524,55 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1452414524
),
1452514525
),
1452614526
);
14527+
14528+
it.effect("keeps the CLI's goal confirmation out of the conversation", () =>
14529+
Effect.gen(function* () {
14530+
const harness = yield* makeWakeHarness;
14531+
const condition = "all tests pass";
14532+
yield* harness.runtime.startTurn(
14533+
makeClaudeTestTurnInput({
14534+
threadId: harness.threadId,
14535+
providerThread: {
14536+
...harness.providerThread,
14537+
goal: { objective: condition, status: "active", checks: 1 },
14538+
},
14539+
now: yield* DateTime.now,
14540+
attemptId: RunAttemptId.make("goal-clear-attempt"),
14541+
text: "/goal clear",
14542+
attachments: [],
14543+
}),
14544+
);
14545+
yield* Queue.offer(
14546+
harness.sdkMessages,
14547+
syntheticFrame("goal-clear", `Goal cleared: ${condition}`),
14548+
);
14549+
yield* Queue.offer(
14550+
harness.sdkMessages,
14551+
makeResultFrame({
14552+
uuid: "goal-clear-result",
14553+
result: `Goal cleared: ${condition}\n`,
14554+
numTurns: 0,
14555+
}),
14556+
);
14557+
yield* Queue.take(harness.terminalReceipts);
14558+
assert.isTrue(
14559+
harness.events.some(
14560+
(event) =>
14561+
event.type === "provider_thread.updated" && event.providerThread.goal === null,
14562+
),
14563+
);
14564+
assert.isFalse(
14565+
harness.events.some(
14566+
(event) => event.type === "message.updated" && event.message.role === "assistant",
14567+
),
14568+
);
14569+
}).pipe(
14570+
Effect.scoped,
14571+
Effect.provide(
14572+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
14573+
),
14574+
),
14575+
);
1452714576
});
1452814577
});
1452914578

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,19 @@ function nextClaudeGoal(
17231723
};
17241724
}
17251725

1726+
/** The CLI confirms a goal change as a synthetic reply; the goal row shows that change instead. */
1727+
function isClaudeGoalAcknowledgement(message: SDKMessage): boolean {
1728+
if (
1729+
message.type !== "assistant" ||
1730+
message.parent_tool_use_id !== null ||
1731+
message.message.model !== "<synthetic>"
1732+
) {
1733+
return false;
1734+
}
1735+
const text = textFromClaudeContent(message.message.content).trim();
1736+
return text.startsWith(CLAUDE_GOAL_SET_PREFIX) || text.startsWith("Goal cleared: ");
1737+
}
1738+
17261739
const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal));
17271740

17281741
function normalizeClaudeResultText(text: string): string {
@@ -8895,6 +8908,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
88958908
});
88968909
return;
88978910
}
8911+
if (isClaudeGoalAcknowledgement(message)) {
8912+
// The CLI repeats a local command's output as the turn's result text.
8913+
if (assistantText !== null) {
8914+
context.assistant.noticeTexts.add(normalizeClaudeResultText(assistantText.text));
8915+
}
8916+
return;
8917+
}
88988918
// Claude Code echoes /compact's stdout ("Not enough messages to compact.") as a
88998919
// synthetic assistant message. The compaction item reports that outcome; it is not a
89008920
// reply.
@@ -8967,7 +8987,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
89678987
context.assistant.fallbackText.length === 0 &&
89688988
resultText !== null &&
89698989
resultText.text.length > 0 &&
8970-
!context.assistant.noticeTexts.has(resultText.text)
8990+
!context.assistant.noticeTexts.has(resultText.text) &&
8991+
!context.assistant.noticeTexts.has(normalizeClaudeResultText(resultText.text))
89718992
) {
89728993
context.assistant.fallbackText = resultText.text;
89738994
context.assistant.fallbackNativeItemId = resultText.nativeItemId;

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts‎

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5958,6 +5958,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
59585958
nativeThreadId,
59595959
nativeTurnId: "goal-turn-1",
59605960
prompt: objective,
5961+
sentPrompt: `Start working toward this goal: ${objective}`,
59615962
}).slice(5),
59625963
5,
59635964
),
@@ -6022,6 +6023,8 @@ describe("CodexAdapterV2 post-settle continuation", () => {
60226023
[providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2],
60236024
],
60246025
);
6026+
// The goal is set paused until its first turn starts; only the result shows.
6027+
assert.notInclude(providerGoals(harness.events), "paused");
60256028
assert.equal(providerGoals(harness.events).at(-1), "complete");
60266029
}).pipe(
60276030
Effect.scoped,
@@ -6204,6 +6207,233 @@ describe("CodexAdapterV2 post-settle continuation", () => {
62046207
),
62056208
);
62066209

6210+
it.effect("pauses a goal Codex does not continue before its run settles", () =>
6211+
Effect.gen(function* () {
6212+
const prompt = "Keep going";
6213+
const transcript = makeCodexReplayTranscript({
6214+
scenario: "goal-continuation-expired",
6215+
entries: [
6216+
...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
6217+
notification("goal active", "thread/goal/updated", {
6218+
threadId: nativeThreadId,
6219+
turnId: "goal-turn",
6220+
goal: codexGoal("active", 10),
6221+
}),
6222+
notification("turn done", "turn/completed", {
6223+
threadId: nativeThreadId,
6224+
turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }),
6225+
}),
6226+
notification("goal accounted", "thread/goal/updated", {
6227+
threadId: nativeThreadId,
6228+
turnId: null,
6229+
goal: codexGoal("active", 11),
6230+
}),
6231+
...request(
6232+
4,
6233+
"thread/goal/set",
6234+
{ threadId: nativeThreadId, status: "paused" },
6235+
{ goal: codexGoal("paused", 11) },
6236+
),
6237+
],
6238+
});
6239+
const harness = yield* makeCodexReplayHarness(transcript);
6240+
yield* harness.runtime.startTurn(
6241+
makeCodexTestTurnInput({
6242+
threadId: harness.threadId,
6243+
providerThread: harness.providerThread,
6244+
now: yield* DateTime.now,
6245+
attemptId: RunAttemptId.make("goal-expired-attempt"),
6246+
text: prompt,
6247+
}),
6248+
);
6249+
yield* harness.until(() =>
6250+
harness.events.some(
6251+
(event) =>
6252+
event.type === "provider_thread.updated" &&
6253+
event.providerThread.goal?.tokensUsed === 11,
6254+
),
6255+
);
6256+
assert.lengthOf(harness.terminalEvents(), 0);
6257+
yield* TestClock.adjust("5 seconds");
6258+
yield* harness.firstTerminal;
6259+
6260+
assert.deepEqual(providerGoals(harness.events), ["active", "active", "paused"]);
6261+
assert.deepEqual(
6262+
harness.terminalEvents().map((event) => event.status),
6263+
["completed"],
6264+
);
6265+
}).pipe(
6266+
Effect.scoped,
6267+
Effect.provide(
6268+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
6269+
),
6270+
),
6271+
);
6272+
6273+
it.effect("stops a turn Codex starts while the expiry pause is in flight", () =>
6274+
Effect.gen(function* () {
6275+
const prompt = "Keep going";
6276+
const transcript = makeCodexReplayTranscript({
6277+
scenario: "goal-continuation-during-expiry",
6278+
entries: [
6279+
...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
6280+
notification("goal active", "thread/goal/updated", {
6281+
threadId: nativeThreadId,
6282+
turnId: "goal-turn",
6283+
goal: codexGoal("active", 10),
6284+
}),
6285+
notification("turn done", "turn/completed", {
6286+
threadId: nativeThreadId,
6287+
turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }),
6288+
}),
6289+
notification("goal accounted", "thread/goal/updated", {
6290+
threadId: nativeThreadId,
6291+
turnId: null,
6292+
goal: codexGoal("active", 11),
6293+
}),
6294+
{
6295+
type: "expect_outbound",
6296+
label: "pause",
6297+
frame: {
6298+
id: 4,
6299+
method: "thread/goal/set",
6300+
params: { threadId: nativeThreadId, status: "paused" },
6301+
},
6302+
},
6303+
notification("goal paused", "thread/goal/updated", {
6304+
threadId: nativeThreadId,
6305+
turnId: null,
6306+
goal: codexGoal("paused", 11),
6307+
}),
6308+
notification("late continuation", "turn/started", {
6309+
threadId: nativeThreadId,
6310+
turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }),
6311+
}),
6312+
...request(
6313+
5,
6314+
"thread/goal/set",
6315+
{ threadId: nativeThreadId, status: "paused" },
6316+
{ goal: codexGoal("paused", 11) },
6317+
),
6318+
...request(
6319+
6,
6320+
"turn/interrupt",
6321+
{ threadId: nativeThreadId, turnId: "goal-turn-2" },
6322+
{},
6323+
),
6324+
{
6325+
type: "emit_inbound",
6326+
label: "paused",
6327+
frame: { id: 4, result: { goal: codexGoal("paused", 11) } },
6328+
},
6329+
],
6330+
});
6331+
const harness = yield* makeCodexReplayHarness(transcript);
6332+
yield* harness.runtime.startTurn(
6333+
makeCodexTestTurnInput({
6334+
threadId: harness.threadId,
6335+
providerThread: harness.providerThread,
6336+
now: yield* DateTime.now,
6337+
attemptId: RunAttemptId.make("goal-race-attempt"),
6338+
text: prompt,
6339+
}),
6340+
);
6341+
yield* harness.until(() =>
6342+
harness.events.some(
6343+
(event) =>
6344+
event.type === "provider_thread.updated" &&
6345+
event.providerThread.goal?.tokensUsed === 11,
6346+
),
6347+
);
6348+
yield* TestClock.adjust("5 seconds");
6349+
yield* harness.firstTerminal;
6350+
6351+
assert.deepEqual(
6352+
harness.terminalEvents().map((event) => [event.providerTurnId, event.status]),
6353+
[
6354+
[
6355+
(yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
6356+
driver: CodexAdapterV2.CODEX_DRIVER_KIND,
6357+
nativeTurnId: "goal-turn",
6358+
}),
6359+
"completed",
6360+
],
6361+
],
6362+
);
6363+
assert.equal(providerGoals(harness.events).at(-1), "paused");
6364+
}).pipe(
6365+
Effect.scoped,
6366+
Effect.provide(
6367+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
6368+
),
6369+
),
6370+
);
6371+
6372+
it.effect("settles the run when Codex never answers the expiry pause", () =>
6373+
Effect.gen(function* () {
6374+
const prompt = "Keep going";
6375+
const transcript = makeCodexReplayTranscript({
6376+
scenario: "goal-expiry-pause-unanswered",
6377+
entries: [
6378+
...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }),
6379+
notification("goal active", "thread/goal/updated", {
6380+
threadId: nativeThreadId,
6381+
turnId: "goal-turn",
6382+
goal: codexGoal("active", 10),
6383+
}),
6384+
notification("turn done", "turn/completed", {
6385+
threadId: nativeThreadId,
6386+
turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }),
6387+
}),
6388+
notification("goal accounted", "thread/goal/updated", {
6389+
threadId: nativeThreadId,
6390+
turnId: null,
6391+
goal: codexGoal("active", 11),
6392+
}),
6393+
{
6394+
type: "expect_outbound",
6395+
label: "pause",
6396+
frame: {
6397+
id: 4,
6398+
method: "thread/goal/set",
6399+
params: { threadId: nativeThreadId, status: "paused" },
6400+
},
6401+
},
6402+
],
6403+
});
6404+
const harness = yield* makeCodexReplayHarness(transcript);
6405+
yield* harness.runtime.startTurn(
6406+
makeCodexTestTurnInput({
6407+
threadId: harness.threadId,
6408+
providerThread: harness.providerThread,
6409+
now: yield* DateTime.now,
6410+
attemptId: RunAttemptId.make("goal-unanswered-attempt"),
6411+
text: prompt,
6412+
}),
6413+
);
6414+
yield* harness.until(() =>
6415+
harness.events.some(
6416+
(event) =>
6417+
event.type === "provider_thread.updated" &&
6418+
event.providerThread.goal?.tokensUsed === 11,
6419+
),
6420+
);
6421+
yield* TestClock.adjust("5 seconds");
6422+
yield* TestClock.adjust("10 seconds");
6423+
yield* harness.firstTerminal;
6424+
6425+
assert.deepEqual(
6426+
harness.terminalEvents().map((event) => event.status),
6427+
["completed"],
6428+
);
6429+
}).pipe(
6430+
Effect.scoped,
6431+
Effect.provide(
6432+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
6433+
),
6434+
),
6435+
);
6436+
62076437
it.effect("pauses an active goal before Stop interrupts its turn", () =>
62086438
Effect.gen(function* () {
62096439
const prompt = "Keep going";

0 commit comments

Comments
 (0)