Skip to content
Closed
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
4 changes: 2 additions & 2 deletions src/tui/commands/built-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export function registerBuiltInCommands(): void {
if (snap === null) {
return { type: "message", text: "No paused or budget-limited goal to resume." };
}
api.kickoff?.(snap.brief || snap.condition, "resume");
api.kickoff(snap.brief || snap.condition, "resume");
return { type: "message", text: `Goal resumed.\n${formatGoalStatus(snap)}` };
}
if (parsed.sub === "clear") {
Expand Down Expand Up @@ -283,7 +283,7 @@ export function registerBuiltInCommands(): void {
};
}
api.set(condition, parsed.opts);
api.kickoff?.(condition, "set");
api.kickoff(condition, "set");
// One-shot banner only — brief lives in GoalView chrome (multi-line here
// used to overflow chrome row accounting and collide with Work).
return { type: "message", text: "Goal set." };
Expand Down
1 change: 1 addition & 0 deletions src/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ describe("/goal command", () => {
pause: () => null,
resume: () => null,
clear: () => {},
kickoff: () => {},
},
};
const result = getCommand("goal")!.handler("", ctx);
Expand Down
5 changes: 2 additions & 3 deletions src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ export type CommandContext = {
pause: () => GoalSnapshot | null;
resume: (opts?: GoalResumeOpts) => GoalSnapshot | null;
clear: () => void;
/** Kick off a turn after set/resume so the agent starts working immediately. */
/** Kick the agent after set/resume. phase defaults to set. */
kickoff?: (condition: string, phase?: "set" | "resume") => void;
/** Kick the agent after set/resume so a turn actually starts. Phase defaults to "set". */
kickoff: (condition: string, phase?: "set" | "resume") => void;
};
};

Expand Down
70 changes: 70 additions & 0 deletions src/tui/goal-kickoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import { goalKickoffUserMessage } from "../agent/goal.js";
import { createGoalKickoff, type GoalKickoffDeps } from "./goal-kickoff.js";

function harness(): { deps: GoalKickoffDeps; sent: string[] } {
const sent: string[] = [];
const deps: GoalKickoffDeps = {
send: (text) => {
sent.push(text);
return Promise.resolve();
},
onSendFailure: () => {},
};
return { deps, sent };
}

describe("createGoalKickoff", () => {
test("set phase delivers the kickoff message down the send path", async () => {
const { deps, sent } = harness();
const kickoff = createGoalKickoff(deps);

kickoff("ship the feature", "set");
// send() is fire-and-forget from kickoff's perspective; flush microtasks.
await Promise.resolve();

// This is the assertion the bug report calls out as missing: the message
// must reach the send path, not merely mutate governor state.
expect(sent).toEqual([goalKickoffUserMessage("ship the feature", "set")]);
expect(sent[0]).toContain("manage_goal");
expect(sent[0]).toContain("manage_tasks");
});

test("resume phase uses the same send path with phase: resume", async () => {
const { deps, sent } = harness();
const kickoff = createGoalKickoff(deps);

kickoff("ship the feature", "resume");
await Promise.resolve();

expect(sent).toEqual([goalKickoffUserMessage("ship the feature", "resume")]);
expect(sent[0]).toContain("Goal resumed.");
});

test("phase defaults to set", async () => {
const { deps, sent } = harness();
const kickoff = createGoalKickoff(deps);

kickoff("ship the feature");
await Promise.resolve();

expect(sent).toEqual([goalKickoffUserMessage("ship the feature", "set")]);
});

test("send failures are routed to onSendFailure, not thrown", async () => {
let failure: unknown;
const deps: GoalKickoffDeps = {
send: () => Promise.reject(new Error("boom")),
onSendFailure: (err) => {
failure = err;
},
};
const kickoff = createGoalKickoff(deps);

kickoff("ship the feature", "set");
await Promise.resolve();
await Promise.resolve();

expect(failure).toBeInstanceOf(Error);
});
});
26 changes: 26 additions & 0 deletions src/tui/goal-kickoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { goalKickoffUserMessage } from "../agent/goal.js";

export type GoalKickoffDeps = {
/**
* Deliver the kickoff message down the same path a typed prompt takes.
* Must serialize behind any in-flight turn so a goal set mid-run cannot
* corrupt it. The ordinary send path already echoes the sent message into
* the transcript in full (the same way any operator prompt does), so
* kickoff needs no separate echo of its own — a second copy would only
* duplicate it.
*/
send: (text: string) => Promise<unknown>;
onSendFailure: (err: unknown) => void;
};

/**
* Builds the `/goal` kickoff handler: turns a set/resume into the lifecycle
* message the agent needs to actually start working, and sends it through
* the ordinary send path.
*/
export function createGoalKickoff(deps: GoalKickoffDeps): (condition: string, phase?: "set" | "resume") => void {
return (condition, phase = "set") => {
const message = goalKickoffUserMessage(condition, phase);
void deps.send(message).catch(deps.onSendFailure);
};
}
45 changes: 27 additions & 18 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { createSessionOperationQueue } from "./session-operation-queue.js";
import { setAgentSourceUnlessClosed } from "./agent-source-sync.js";
import { createChatDirector } from "../agent/director.js";
import { createGoalGovernor } from "../agent/goal.js";
import { createGoalKickoff } from "./goal-kickoff.js";
import { createGoalEvaluator } from "../agent/goal-evaluator.js";
import { loadGoalState, saveGoalState } from "../session/goal-state.js";
import { loadAgentProfiles, type AgentProfile } from "../agent/profiles.js";
Expand Down Expand Up @@ -1756,6 +1757,24 @@ export async function runTUI(initialConfig: Config): Promise<number> {
// session can show them.
}

const systemRow = (text: string): void => {
appendStreamRow(host.shell, { role: "system", text, meta: "command" });
};

/** Settle the shell after a rejected send so the run does not look live. */
const handleSendFailure = (err: unknown): void => {
const kind = classifyAgentSendFailure(
err,
sendAborted,
isCodexAuthError,
isXaiAuthError,
);
if (!shouldSettleUiAfterSendFailure(kind)) return;
recordRunError(err);
systemRow(err instanceof Error ? err.message : String(err));
setShellRunState(host.shell, "idle");
};

const commandContext: CommandContext = {
signalClear: newSession,
getCostSummary: (): CostSummary => {
Expand Down Expand Up @@ -1800,27 +1819,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
pause: () => goalGovernor.pause(),
resume: (opts) => goalGovernor.resume(opts),
clear: () => goalGovernor.clear(),
// Routed through agentProxy.send, the same queue-safe path every typed
// prompt and command "send" result uses: it awaits any in-flight
// turn's tail first, so a goal set mid-run cannot corrupt it — the
// kickoff simply starts once the turn settles.
kickoff: createGoalKickoff({
send: (text) => agentProxy.send(text),
onSendFailure: handleSendFailure,
}),
},
};

const systemRow = (text: string): void => {
appendStreamRow(host.shell, { role: "system", text, meta: "command" });
};

/** Settle the shell after a rejected send so the run does not look live. */
const handleSendFailure = (err: unknown): void => {
const kind = classifyAgentSendFailure(
err,
sendAborted,
isCodexAuthError,
isXaiAuthError,
);
if (!shouldSettleUiAfterSendFailure(kind)) return;
recordRunError(err);
systemRow(err instanceof Error ? err.message : String(err));
setShellRunState(host.shell, "idle");
};

// The permissions surface addresses grants by their position in the last
// listing, so revoke resolves against the same snapshot the operator saw.
let listedGrants: readonly ScopedApproval[] = [];
Expand Down
Loading