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
38 changes: 23 additions & 15 deletions packages/coding-agent/docs/step-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,21 +278,29 @@ editor refocus/disposal callback is cancelled before mounting. This prevents
an orphaned promise, overlay, or timeout. Ordinary dismissal still allows the
refocused editor to open the next dialog.

### Fused verification (`then_run`)

`edit_file` and `write_file` accept an optional `then_run` shell command. It
runs only after the mutation succeeds (including a no-op), from the initial
working directory like `run_command` without `cwd`, with the same timeout and
output cap. Its output and exit status are appended to the mutation receipt
and recorded in `details.thenRun`. A failing check does not mark the call as
an error, because the file change has already been applied. Empty or
whitespace-only values are ignored.

The Step extension gates `then_run` as an embedded `run_command` call: the
permission decision is the stricter of the mutation and the command
(including `run_command` overrides and dangerous-command analysis), the
confirmation reason names the command, workflow path ACLs check it, and the
call is blocked when `run_command` is not active in the session. SDK
### Fused commands (`then_run`)

`edit_file` and `write_file` accept an optional `then_run` shell command. After
an edit succeeds (including a no-op), the command uses the same execution path
as `run_command` without `cwd`: the initial working directory, the host's
configured timeout or the default 120 seconds, and the same output cap and
full-output retention. Use a separate `run_command` when a check needs custom
`cwd`, `timeout_ms`, `max_output_chars`, or background execution. Empty or
whitespace-only `then_run` values are ignored.

The command output and exit status are appended to the mutation receipt and
recorded in `details.thenRun`. When inline output is shortened, its full-log
reference remains visible and is available in `details.thenRun.fullOutputPath`;
read that log instead of rerunning the command to recover its output. A failing
or timed-out check does not mark the combined call as an error or roll back the
file change. Caller cancellation still propagates with the mutation receipt.

The Step extension gates `then_run` as an embedded `run_command` call before
performing either action: the permission decision is the stricter of the
mutation and the command (including `run_command` overrides and
dangerous-command analysis), the confirmation reason names the command,
workflow path ACLs check it, and the call is blocked when `run_command` is not
active in the session. A denied combined call does not apply the edit. SDK
`acceptEdits` does not auto-approve an edit carrying `then_run`. SDK
`PreToolUse` hooks and third-party `tool_call` handlers see it as
`input.then_run`; embedders that skip the Step extension must gate it
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/step/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export function buildStepSystemPromptAppendix(

if ((active.has("edit_file") || active.has("write_file")) && active.has("run_command")) {
sections.push(
"- To verify a change immediately, pass the check as then_run on edit_file or write_file (for example a focused test or typecheck) instead of a separate run_command call; it runs only after the change succeeds and needs the same approval as run_command.",
"- For a short check that fits the default command settings, use then_run on edit_file or write_file; it is an embedded run_command with the same execution defaults and approval rules. Use run_command separately for custom cwd, timeout, output limits, or background execution. Inspect the appended command status: a successful file change does not imply a successful check.",
);
}

Expand Down
68 changes: 47 additions & 21 deletions packages/coding-agent/src/step/tool-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const READ_FILE_DESCRIPTION =
const WRITE_FILE_DESCRIPTION =
"Write full content to a file, creating parent directories if missing. Overwrites existing content — for existing files prefer edit_file.";
const THEN_RUN_DESCRIPTION =
"Optional shell command to run after the change succeeds, from the initial working directory like run_command (e.g. a focused test or typecheck); its output and exit status are appended to this result, saving a separate run_command call. Requires the same approval as run_command.";
"Optional run_command command after a successful file change, including a no-op. Uses the initial working directory and run_command defaults for timeout and output. Approval covers both actions before editing; a denied call makes no change. If the command fails or times out after editing, the file change remains applied: inspect the appended command status. Use a separate run_command for custom cwd, timeout, output limits, or background execution.";

const listDirectorySchema = Type.Object({
path: Type.Optional(Type.String({ description: `Directory path. ${PATH_DESCRIPTION} Defaults to '.'` })),
Expand Down Expand Up @@ -1079,48 +1079,68 @@ function mapRunCommandArgs(args: RunCommandInput, ctx?: ExtensionContext): { com
};
}

/** Preserve command recovery metadata when the Step output cap also shortens an error. */
class StepCommandError extends ShellToolError {
readonly stepTruncated: boolean;

constructor(error: ShellToolError, maxChars: number) {
const limited = limitRunCommandText(error.message, maxChars, error.fullOutputPath);
super(limited.text, error.fullOutputPath);
this.cause = error;
this.stepTruncated = limited.truncated;
}
}

export interface StepThenRunDetails {
command: string;
exitCode: number | null;
timedOut: boolean;
/** Whether run_command's Step character cap shortened its inline output. */
stepTruncated: boolean;
/** Complete command output, retained by run_command when inline output is shortened. */
fullOutputPath?: string;
}

/**
* Action fusion: run the `then_run` verification command after a successful
* file mutation and fold its outcome into the mutation result. The native
* bash tool throws on non-zero exits; a failing check must not hide the
* already-applied mutation, so those throws become part of the fused output
* and the result is not marked as an error. Caller cancellation still
* propagates, carrying the mutation receipt.
* Run the embedded run_command after a successful file mutation. A failing
* command must not hide or repeat the already-applied mutation. Caller
* cancellation still propagates with the mutation receipt.
*/
async function withThenRunResult(
result: AnyResult,
nativeBash: AnyToolDefinition,
runCommand: ToolDefinition<typeof runCommandSchema>,
command: string,
signal: AbortSignal | undefined,
ctx: ExtensionContext | undefined,
): Promise<AnyResult> {
const configuredTimeout = getContextValue<number>(ctx, "commandTimeoutMs");
const receipt = textFromResult(result);
let output: string;
let status: string | undefined;
let exitCode: number | null = 0;
let timedOut = false;
let stepTruncated = false;
let fullOutputPath: string | undefined;
try {
// No onUpdate: bash partials would reach the edit/write renderers.
const bashResult = await nativeBash.execute(
// No onUpdate: command partials would reach the edit/write renderers.
const commandResult = await runCommand.execute(
"step-then-run",
{ command, timeout: typeof configuredTimeout === "number" ? configuredTimeout / 1000 : undefined },
{ command },
signal,
undefined,
ctx as ExtensionContext,
);
output = textFromResult(bashResult);
output = textFromResult(commandResult);
const details = nativeDetails(commandResult);
stepTruncated = details.stepTruncated === true;
fullOutputPath = typeof details.fullOutputPath === "string" ? details.fullOutputPath : undefined;
status = "Command exited with code 0";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isCallerAborted(signal)) throw new Error(`${receipt}\n\nthen_run: $ ${command}\n${message}`);
if (isCallerAborted(signal)) {
throw new Error(`${receipt}\n\nthen_run: $ ${command}\n${message}`, { cause: error });
}
stepTruncated = error instanceof StepCommandError && error.stepTruncated;
fullOutputPath = error instanceof ShellToolError ? error.fullOutputPath : undefined;
const match = /(?:^|\n\n)(Command exited with code (\d+)|Command timed out after .+ seconds)$/u.exec(message);
if (match) {
output = message.slice(0, match.index);
Expand All @@ -1132,14 +1152,21 @@ async function withThenRunResult(
exitCode = null;
}
}
const limited = withStepTextLimit("run_command", output || "(no output)", resolveStepMaxChars(undefined, ctx));
const thenRun: StepThenRunDetails = { command, exitCode, timedOut, stepTruncated: limited.truncated };
// run_command already bounded the output and preserved its log/status footer.
// Limiting it again here could discard the only reference to the full output.
const thenRun: StepThenRunDetails = {
command,
exitCode,
timedOut,
stepTruncated,
...(fullOutputPath ? { fullOutputPath } : {}),
};
return {
...result,
content: [
{
type: "text",
text: `${receipt}\n\n${thenRunMarker(command)}${limited.text}${status ? `\n\n${status}` : ""}`,
text: `${receipt}\n\n${thenRunMarker(command)}${output || "(no output)"}${status ? `\n\n${status}` : ""}`,
},
],
details: { ...nativeDetails(result), thenRun },
Expand Down Expand Up @@ -1583,7 +1610,7 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio
) => {
const result = await executeStepWriteFile(args, cwd, options.write, signal, ctx);
const thenRun = readThenRunCommand(args);
return thenRun === undefined ? result : withThenRunResult(result, nativeBash, thenRun, signal, ctx);
return thenRun === undefined ? result : withThenRunResult(result, runCommand, thenRun, signal, ctx);
},
renderResult: withThenRunRenderResult(writeFileBase.renderResult as Renderer["renderResult"]),
} as AnyToolDefinition;
Expand All @@ -1604,7 +1631,7 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio
execute: async (toolCallId, args, signal, onUpdate, ctx) => {
const result = await executeStepEdit(nativeEdit, toolCallId, args, cwd, options.edit, signal, onUpdate, ctx);
const thenRun = readThenRunCommand(args);
return thenRun === undefined ? result : withThenRunResult(result, nativeBash, thenRun, signal, ctx);
return thenRun === undefined ? result : withThenRunResult(result, runCommand, thenRun, signal, ctx);
},
renderCall: nativeEdit.renderCall
? (args, theme, context) =>
Expand Down Expand Up @@ -1653,8 +1680,7 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio
return applyStepTextLimit(result, "run_command", maxChars);
} catch (error) {
if (error instanceof ShellToolError) {
const limited = limitRunCommandText(error.message, maxChars, error.fullOutputPath);
if (limited.truncated) throw new Error(limited.text, { cause: error });
throw new StepCommandError(error, maxChars);
}
throw error;
}
Expand Down
Loading
Loading