Skip to content
Merged
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
21 changes: 3 additions & 18 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,6 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
!policyBlocksCreateSend;
const runningGoalActive =
variant === "workspace" && isGoalRunning(workspaceGoal?.status ?? "paused");
const shouldDefaultSteerGoal = runningGoalActive && !editingMessageForUi;

// Send defaults to tool-end on click; advanced dispatch modes remain available via
// right-click and touch long-press whenever there's a sendable workspace draft.
Expand Down Expand Up @@ -2252,8 +2251,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const commandHandled = modelOneShot
? false
: await executeParsedCommand(parsed, input, {
goalInterventionPolicy:
overrides?.goalInterventionPolicy ?? (shouldDefaultSteerGoal ? "steer" : undefined),
goalInterventionPolicy: overrides?.goalInterventionPolicy,
queueDispatchMode: overrides?.queueDispatchMode,
});
if (commandHandled) {
Expand Down Expand Up @@ -2444,8 +2442,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
rawThinkingOverride != null
? resolveThinkingInput(rawThinkingOverride, policyModel)
: undefined;
const goalInterventionPolicy =
overrides?.goalInterventionPolicy ?? (shouldDefaultSteerGoal ? "steer" : undefined);
const goalInterventionPolicy = overrides?.goalInterventionPolicy;

const sendOptions = {
...sendMessageOptions,
Expand Down Expand Up @@ -3058,7 +3055,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
{runningGoalActive && !editingMessageForUi && (
<>
<br />
Send and pause goal: right-click menu
Manual sends pause the current goal; use Resume to continue it.
</>
)}
{SEND_DISPATCH_MODES.map((entry) => (
Expand Down Expand Up @@ -3094,18 +3091,6 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
</kbd>
</button>
))}
{runningGoalActive && !editingMessageForUi && (
<button
type="button"
className="hover:bg-hover focus-visible:bg-hover text-foreground flex w-full items-center justify-between gap-2 rounded-sm px-2.5 py-1 text-left text-xs"
onClick={() => {
closeSendModeMenu();
void handleSend({ goalInterventionPolicy: "pause" });
}}
>
<span className="whitespace-nowrap">Send and pause goal</span>
</button>
)}
</div>
)}
</div>
Expand Down
17 changes: 16 additions & 1 deletion src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
} from "@/common/utils/goals/budgetParser";
import {
CLI_GOAL_STREAM_START_TIMEOUT_MS,
GOAL_CONTINUATION_KIND,
GOAL_CONTINUATION_IDLE_CONSUMER_NAME,
} from "@/constants/goals";
import type { GoalRecordV1 } from "@/common/types/goal";
Expand Down Expand Up @@ -960,7 +961,21 @@ async function main(): Promise<number> {

const sendAndAwait = async (msg: string, options: SendMessageOptions): Promise<void> => {
completionPromise = createCompletionPromise();
const sendResult = await session.sendMessage(msg, options);
const sendResult = await session.sendMessage(
msg,
options,
hasGoal
? {
// CLI goal runs suppress the desktop kickoff dispatcher and drive
// their own user turns, so mark them as the durable goal
// continuations that keep goal mode active.
synthetic: true,
agentInitiated: true,
goalKind: GOAL_CONTINUATION_KIND,
goalContinuation: true,
Comment thread
ammar-agent marked this conversation as resolved.
}
Comment thread
ammar-agent marked this conversation as resolved.
: undefined
);
if (!sendResult.success) {
const errorValue = sendResult.error;
let formattedError = "unknown error";
Expand Down
5 changes: 5 additions & 0 deletions src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,11 @@ export const ExperimentsSchema = z.object({
imageGenerationTool: z.boolean().optional(),
});

/**
* `steer` is accepted for older clients, but the backend treats every manual
* user message as a pause because active goal mode is derived from the latest
* `goal_continuation` user turn.
*/
export const GoalInterventionPolicySchema = z.enum(["steer", "pause"]);

// SendMessage options
Expand Down
7 changes: 4 additions & 3 deletions src/common/types/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ export type GoalLifecycle = "active" | "complete";
* agent is doing with it right now:
*
* - `running` — the agent may auto-continue this goal.
* - `paused` — explicit user pause, explicit send-and-pause, or safety
* gates such as an interrupted stream. Continuations are
* suppressed until the user resumes.
* - `paused` — latest user turn is not a goal continuation (manual
* intervention, explicit pause), or safety gates such as
* an interrupted stream. Continuations are suppressed
* until the user resumes.
* - `budget_limited` — internal-only transient state set by the budget
* gate when cost or turn caps are hit.
*
Expand Down
3 changes: 3 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,9 @@ export type MuxMessageMetadata = MuxMessageMetadataBase &
| {
type: "goal-cleared-summary";
}
| {
type: "goal-pause-boundary";
}
| {
type: "heartbeat-request";
/** Synthetic heartbeat follow-ups use an explicit marker so future backend dispatch stays inspectable. */
Expand Down
42 changes: 22 additions & 20 deletions src/node/services/agentSession.goalAutoPause.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,19 @@ describe("AgentSession goal safety hooks", () => {
}
});

test("manual user messages steer active goals by default", async () => {
const workspaceId = "manual-steers-active-goal";
test("manual user messages pause active goals by default", async () => {
const workspaceId = "manual-pauses-active-goal-by-default";
const { session, goalService, analytics, cleanup } = await createSessionHarness(workspaceId);
cleanups.push(cleanup);
await setGoalOk(goalService, { workspaceId, objective: "Keep working" });

const result = await session.sendMessage("I need to steer this goal", SEND_OPTIONS);
const result = await session.sendMessage("I need to pause this goal with a note", SEND_OPTIONS);

expect(result.success).toBe(true);
expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" });
expect(analytics.recordGoalLifecycleEvent).not.toHaveBeenCalledWith(
expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "paused" });
expect(analytics.recordGoalLifecycleEvent).toHaveBeenCalledWith(
"goal_paused",
expect.anything()
expect.objectContaining({ initiator: "auto" })
);
session.dispose();
});
Expand Down Expand Up @@ -216,7 +216,7 @@ describe("AgentSession goal safety hooks", () => {
session.dispose();
});

test("manual user messages clear acknowledgment flags while steering by default", async () => {
test("manual user messages clear acknowledgment flags while pausing by default", async () => {
const workspaceId = "manual-clears-ack";
const { session, goalService, cleanup } = await createSessionHarness(workspaceId);
cleanups.push(cleanup);
Expand All @@ -227,7 +227,7 @@ describe("AgentSession goal safety hooks", () => {

expect(result.success).toBe(true);
expect(await goalService.getGoal(workspaceId)).toMatchObject({
status: "active",
status: "paused",
requireUserAcknowledgmentSinceMs: null,
});
session.dispose();
Expand Down Expand Up @@ -436,7 +436,7 @@ describe("AgentSession goal safety hooks", () => {
const manualResult = await session.sendMessage("I saw the recovered response", SEND_OPTIONS);
expect(manualResult.success).toBe(true);
expect(await goalService.getGoal(workspaceId)).toMatchObject({
status: "active",
status: "paused",
requireUserAcknowledgmentSinceMs: null,
});

Expand Down Expand Up @@ -563,20 +563,19 @@ describe("AgentSession goal safety hooks", () => {
return Promise.resolve(Ok(undefined));
}) as unknown as AIService["streamMessage"];

// Manual user messages steer active goals by default (#3319), so the
// goal stays `active` rather than auto-pausing. The point of this test
// is that the silent-continuation auto-completion does NOT fire on
// manual turns — `activeStreamContext.goalKind` is undefined on a
// manual send, so the silent-completion gate
// (`goalKind === GOAL_CONTINUATION_KIND`) short-circuits and status
// stays `active`, not `complete`.
// Manual user messages now pause active goals because the goal mode is
// locked to the latest user message kind. The point of this test is that
// silent-continuation auto-completion still does NOT fire on manual turns:
// `activeStreamContext.goalKind` is undefined on a manual send, so the
// silent-completion gate (`goalKind === GOAL_CONTINUATION_KIND`)
// short-circuits and status stays `paused`, not `complete`.
const result = await session.sendMessage("Manual question", SEND_OPTIONS);
expect(result.success).toBe(true);

// Give the async stream-end handler a tick to run so any stray
// auto-completion would have a chance to corrupt the steering state.
// auto-completion would have a chance to corrupt the paused state.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" });
expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "paused" });
session.dispose();
});

Expand Down Expand Up @@ -613,7 +612,7 @@ describe("AgentSession goal safety hooks", () => {
session.dispose();
});

test("text-only stream-end during a goal_continuation turn does not affect paused goals", async () => {
test("text-only goal_continuation turn can complete a resumed paused goal", async () => {
const workspaceId = "silent-continuation-paused";
const { session, goalService, aiService, cleanup } = await createSessionHarness(workspaceId);
cleanups.push(cleanup);
Expand Down Expand Up @@ -641,7 +640,10 @@ describe("AgentSession goal safety hooks", () => {
expect(result.success).toBe(true);

await new Promise((resolve) => setTimeout(resolve, 50));
expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "paused" });
expect(await goalService.getGoal(workspaceId)).toMatchObject({
status: "complete",
completionSummary: "All wrapped up.",
});
session.dispose();
});
});
44 changes: 33 additions & 11 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,12 +1125,23 @@ export class AgentSession {
);
}

private isSyntheticGoalPauseBoundaryMessage(message: MuxMessage): boolean {
return (
message.role === "user" &&
message.metadata?.synthetic === true &&
message.metadata.muxMetadata?.type === "goal-pause-boundary"
);
}

private getLastNonSystemHistoryMessage(historyTail: MuxMessage[]): MuxMessage | undefined {
for (let index = historyTail.length - 1; index >= 0; index -= 1) {
const candidate = historyTail[index];
if (candidate.role === "system") {
continue;
}
if (this.isSyntheticGoalPauseBoundaryMessage(candidate)) {
continue;
}
if (this.isSyntheticSnapshotUserMessage(candidate)) {
continue;
}
Expand Down Expand Up @@ -1184,13 +1195,15 @@ export class AgentSession {
`invalid goal intervention policy: ${input.policy}`
);

// Accepted manual user turns acknowledge /clear or crash-recovery gates. Steering
// is the default for normal sends: the agent should see the user's intervention
// and the active goal may continue afterward. Rejected sends use "pause" below
// because the model never saw the steering content.
// Accepted manual user turns acknowledge / clear crash-recovery gates, but
// they are no longer goal-continuation turns. The goal mode is locked to the
// chat tail: a real `goal_continuation` user message means running;
// anything manually typed by the user pauses until Resume appends a fresh
// continuation. Legacy clients may still send the old "steer" policy; treat
// it as pause so the invariant holds at this backend boundary.
goalService.clearPendingContinuationForManualUserMessage(this.workspaceId);
const goal = await goalService.acknowledgeUser(this.workspaceId);
if (goal?.status !== "active" || input.policy !== "pause") {
if (goal?.status !== "active") {
return;
}

Expand Down Expand Up @@ -1233,6 +1246,9 @@ export class AgentSession {
return false;
}

if (this.isSyntheticGoalPauseBoundaryMessage(message)) {
return false;
}
if (this.isSyntheticSnapshotUserMessage(message)) {
return false;
}
Expand Down Expand Up @@ -2172,6 +2188,7 @@ export class AgentSession {
agentInitiated?: boolean;
goalContinuation?: boolean;
goalKind?: GoalSyntheticMessageKind;
startStreamInBackground?: boolean;
onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise<void> | void;
}
): Promise<Result<void, SendMessageError>> {
Expand Down Expand Up @@ -2238,7 +2255,7 @@ export class AgentSession {
const editMessageId = options?.editMessageId;

const manualGoalInterventionPolicy: GoalInterventionPolicy | undefined = isManualUserMessage
? (options?.goalInterventionPolicy ?? (editMessageId ? "pause" : "steer"))
? (options?.goalInterventionPolicy ?? "pause")
: undefined;

// Edits are implemented as truncate+replace. If the frontend omits fileParts,
Expand Down Expand Up @@ -2667,6 +2684,8 @@ export class AgentSession {
}
}

await this.workspaceGoalService?.syncGoalModeWithChatTail(this.workspaceId);

if (manualGoalInterventionPolicy != null) {
await this.applyManualUserMessageGoalSafety({ policy: manualGoalInterventionPolicy });
}
Expand Down Expand Up @@ -2767,19 +2786,22 @@ export class AgentSession {
}
};

if (editMessageId) {
// The edit is already persisted + emitted above, so let callers unblock immediately instead of
// waiting for runtime warmup / stream-start to finish before they can clear edit state.
void startPreparedStream()
if (editMessageId || internal?.startStreamInBackground === true) {
// The user turn is already persisted + emitted above. Edits and backend
// goal continuations should unblock once the user message exists: for
// Resume, that makes chat history the durable source of truth for the
// running goal before runtime warmup or streaming can race/fail.
startPreparedStream()
.then(async (result) => {
if (!result.success) {
await internal?.onAcceptedPreStreamFailure?.(result.error);
}
Comment thread
ammar-agent marked this conversation as resolved.
})
.catch((error: unknown) => {
log.error("Accepted edit stream failed before startup completed", {
log.error("Accepted background stream failed before startup completed", {
workspaceId: this.workspaceId,
editMessageId,
goalKind,
error: getErrorMessage(error),
});
});
Expand Down
Loading
Loading