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
4 changes: 4 additions & 0 deletions src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,11 +1039,15 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
if (unfinished) {
// O99 / O98: progress-path footer via formatOutcomeLine (never empty).
// Exit code is owned by oneshot resolveRunOutcome.
// O113: pass the progress completionTracker so budget/turn-limit
// stderr matches stdout (e.g. 「卡在:运行预算(read_file: …)」).
const zh = isZhLocale();
const prog = ensureProgress();
stderrLine(
`${mark('fail')} ${formatProgressOutcomeLine({
stopReason,
finalText: event.result?.response ?? '',
tracker: prog.completionTracker(),
zh,
})}`
);
Expand Down
31 changes: 30 additions & 1 deletion src/cli/run-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export interface RunCompletionTracker {
wroteFiles: boolean;
/** Last failed tool step, e.g. `exec: npm run check`. */
lastFailedStep?: string;
/**
* O113: last tool step blocked by safety / approval (not a normal tool fail).
* When set, the incomplete last line uses 「被拦:…」 / `Blocked by: …`.
*/
lastBlockedStep?: string;
/** Last tool step that started, e.g. `multi_edit: src/cli/hooks.ts`. */
lastStep?: string;
/** Latest MOSS_GOAL_VERIFY_CMD result (O98 — must feed the tracker). */
Expand Down Expand Up @@ -140,6 +145,23 @@ function execFailed(result: string): boolean {
return /\bexit(?:ed)?(?:\s+code)?[\s:=]+[1-9]\d*\b/i.test(result);
}

/**
* O113: true when a tool_end result is a safety / approval / policy block
* (not a normal tool failure like exit 1 or missing file).
*/
export function isSafetyOrApprovalBlockResult(result: string | undefined): boolean {
if (!result) return false;
const head = result.slice(0, 600);
return (
/^\s*Command blocked:/i.test(head) ||
/^\s*Blocked dangerous command:/i.test(head) ||
/^\s*已拦截/u.test(head) ||
/^\s*Blocked\b/i.test(head) ||
/^\s*Tool execution denied\b/i.test(head) ||
/^\s*Operation blocked by workspace policy\b/i.test(head)
);
}

/** Feed one agent event into the tracker. Safe to call for every event. */
export function noteRunCompletionEvent(tracker: RunCompletionTracker, event: MossAgentEvent): void {
if (event.type === 'tool_start') {
Expand All @@ -153,7 +175,14 @@ export function noteRunCompletionEvent(tracker: RunCompletionTracker, event: Mos
const input = started?.input ?? {};
const toolName = started?.toolName ?? event.toolName;
const failed = event.isError || (EXEC_TOOLS.has(toolName) && execFailed(event.result));
if (failed) tracker.lastFailedStep = describeStep(toolName, input);
if (failed) {
const step = describeStep(toolName, input);
tracker.lastFailedStep = step;
// O113: remember safety/approval blocks separately so the last line can say 被拦.
if (isSafetyOrApprovalBlockResult(event.result)) {
tracker.lastBlockedStep = step;
}
}

if (FILE_WRITE_TOOLS.has(toolName) && !failed) tracker.wroteFiles = true;
if (toolName === 'run_tests')
Expand Down
35 changes: 33 additions & 2 deletions src/cli/run-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export interface RunOutcome {
stuckAt?: string;
/** Where the run got stuck (Chinese), when known. */
stuckAtZh?: string;
/**
* O113: when true, the stuck half uses 「被拦:…」 / `Blocked by: …`
* (safety/approval block). Otherwise 「卡在:…」 / `stuck at: …`.
*/
stuckWasBlocked?: boolean;
}

export interface ResolveRunOutcomeInput {
Expand Down Expand Up @@ -233,7 +238,11 @@ export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome {

// O111: unfinished class (turn/budget/…) plus the last tool step when both
// are known, e.g. `run budget (read_file: src/a.ts)`; either alone otherwise.
// O113: a safety/approval block alone uses 「被拦」; budget/turn-limit classes
// stay 「卡在」 even when a blocked tool also ran (do not mislabel budget).
let stuckWasBlocked = false;
if (!stuckAt) {
const blockedStep = tracker.lastBlockedStep;
const step = tracker.lastFailedStep ?? tracker.lastStep;
const cls = input.runError ? stuckClassFromUnfinished(input.runError) : undefined;
if (cls && step) {
Expand All @@ -242,6 +251,10 @@ export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome {
} else if (cls) {
stuckAt = cls.en;
stuckAtZh = cls.zh;
} else if (blockedStep) {
stuckAt = blockedStep;
stuckAtZh = blockedStep;
stuckWasBlocked = true;
} else if (step) {
stuckAt = step;
stuckAtZh = step;
Expand All @@ -260,6 +273,7 @@ export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome {
exitCode,
...(stuckAt ? { stuckAt } : {}),
...(stuckAtZh ? { stuckAtZh } : {}),
...(stuckWasBlocked ? { stuckWasBlocked: true } : {}),
};
}

Expand All @@ -272,8 +286,17 @@ function head(label: string, reason: string | undefined, zh: boolean): string {
return zh ? `${label}:${reason}` : `${label}: ${reason}`;
}

function appendStuckHalf(base: string, stuck: string | undefined, zh: boolean): string {
function appendStuckHalf(
base: string,
stuck: string | undefined,
zh: boolean,
blocked = false
): string {
if (!stuck) return base;
// O113: safety/approval block → 「被拦:…」 / `Blocked by: …`
if (blocked) {
return zh ? `${base};被拦:${stuck}` : `${base}; Blocked by: ${stuck}`;
}
return zh ? `${base};卡在:${stuck}` : `${base}; stuck at: ${stuck}`;
}

Expand Down Expand Up @@ -317,6 +340,9 @@ export function formatOutcomeLine(outcome: RunOutcome, lang: OutcomeLang): strin
// O111: no placeholder stuck step — omit the stuck half when unset.
const stuckRaw = zh ? outcome.stuckAtZh : outcome.stuckAt;
const stuck = stuckRaw?.replace(/\s+/g, ' ').trim() || undefined;
// O113: incomplete + safety block uses 「被拦」 half; Stop-hook `blocked`
// status already carries 「被拦」/Blocked in the head — keep stuck half as 卡在.
const blockedHalf = outcome.stuckWasBlocked === true && outcome.status === 'incomplete';
switch (outcome.status) {
case 'completed':
return head(zh ? '完成' : 'Completed', reason, zh);
Expand All @@ -328,6 +354,11 @@ export function formatOutcomeLine(outcome: RunOutcome, lang: OutcomeLang): strin
return appendStuckHalf(head(zh ? '被拦' : 'Blocked', reason, zh), stuck, zh);
case 'incomplete':
default:
return appendStuckHalf(head(zh ? '未完成' : 'Incomplete', reason, zh), stuck, zh);
return appendStuckHalf(
head(zh ? '未完成' : 'Incomplete', reason, zh),
stuck,
zh,
blockedHalf
);
}
}
8 changes: 8 additions & 0 deletions src/cli/run-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export interface RunProgressTracker {
dispose(): void;
/** Test helper: emitted lines in order. */
lines(): readonly ProgressLine[];
/**
* O113: the O98 completion tracker fed by onEvent — pass to
* {@link formatProgressOutcomeLine} so stderr conclusions share lastStep.
*/
completionTracker(): RunCompletionTracker;
}

/**
Expand Down Expand Up @@ -449,5 +454,8 @@ export function createRunProgressTracker(
lines() {
return emitted;
},
completionTracker() {
return completionTracker;
},
};
}
4 changes: 2 additions & 2 deletions test/o111-stuck-no-unknown.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ function failTool(t, name = 'exec', cmd = 'npm run check') {
});
assert.equal(o.status, 'blocked');
assert.equal(o.stuckAt, 'Stop hook');
assert.equal(o.stuckAtZh, 'Stop 钩子');
assert.equal(o.stuckAtZh, '停止前钩子'); // O91 zh display name
const zh = formatOutcomeLine(o, 'zh');
const en = formatOutcomeLine(o, 'en');
assert.match(zh, /^被拦:.*;卡在:Stop 钩子$/);
assert.match(zh, /^被拦:.*;卡在:停止前钩子$/);
assert.match(en, /^Blocked: .*stuck at: Stop hook$/);
banUnknown(zh, 'B4 zh');
banUnknown(en, 'B4 en');
Expand Down
Loading
Loading