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
10 changes: 6 additions & 4 deletions apps/cli/src/ui/runtime/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,12 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
keepLatestTaskUpdateResult(ctx.chatContainer, event.toolCallId, component, event.isError);
ctx.pendingTools.delete(event.toolCallId);
ctx.stepSpinner?.stop(event.toolCallId);
// 工具结束不立刻降级动词:瞬时工具(read ~300ms)的动词如果
// 立刻换回 Working... 肉眼不可感知。保持最后动作,直到下一
// 个工具开始 / 模型思考 / 4s tick 自然过渡——刚完成的动作
// 停留显示不算谎言,看不见才是问题。
if (ctx.presentation === "step") {
ctx.workingOutputTracker.notifyToolEnded();
if (ctx.activeStatusIndicator instanceof WorkingStatusIndicator) {
ctx.activeStatusIndicator.refreshVerb();
}
}
ctx.redraw.requestRender();
}
break;
Expand Down
19 changes: 9 additions & 10 deletions apps/cli/src/ui/view/chrome/status-indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,7 @@ export interface WorkingOutputSnapshot {
elapsedSeconds: number;
outputTokens: number;
phase: "thinking" | undefined;
/**
* False from the moment a tool starts until the model emits new prose
* (text delta) or thinking. While false the rotation must NOT demote the
* verb to "Working..." — a short tool's verb would otherwise be visible
* for a random 0-4s slice of the tick phase, i.e. not at all.
*/
/** Whether the working status may fall back from its last tool action. */
idleVerbAllowed: boolean;
}

Expand All @@ -38,11 +33,17 @@ export class WorkingOutputTracker {
this.idleVerbAllowed = true;
}

/** A tool started: hold its verb until the model produces new output. */
/** Keep the tool action visible while execution is active. */
notifyToolStarted(): void {
this.idleVerbAllowed = false;
}

/** Release the completed action without waiting for the next model response. */
notifyToolEnded(): void {
this.idleVerbAllowed = true;
this.phase = undefined;
}

update(event: AssistantMessageEvent): void {
switch (event.type) {
case "thinking_delta":
Expand Down Expand Up @@ -261,9 +262,7 @@ export class WorkingStatusIndicator extends StatusIndicator {
this.setMessage(toolVerb);
return;
}
// 工具已结束但模型还没有新输出:保持当前动词(工具动词黏性),
// 否则瞬时工具的动词只在一个随机 0-4s 的 tick 相位切片里可见。
// 黏性期间残留的 thinking 相位也不得覆盖(read 常紧跟 thinking 发起)。
// Tool completion releases this hold even if the next model request is slow.
if (!snapshot.idleVerbAllowed) return;
if (snapshot.phase === "thinking") {
this.setMessage("Thinking...");
Expand Down
121 changes: 121 additions & 0 deletions apps/cli/test/tool-completion-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { initTheme } from "@step-harness/coding-agent";
import { Container, stripTerminalSequences, type TUI } from "@step-harness/pi-tui";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeContext } from "../src/ui/runtime/context.ts";
import { handleSessionEvent } from "../src/ui/runtime/session-events.ts";
import { WorkingOutputTracker, WorkingStatusIndicator } from "../src/ui/view/chrome/status-indicator.ts";
import { StepToolSpinnerClock } from "../src/ui/view/transcript/step-spinner.ts";
import { ToolExecutionComponent } from "../src/ui/view/transcript/tool-execution.ts";

function createView() {
const ui = { requestRender: vi.fn() } as unknown as TUI;
const spinner = new StepToolSpinnerClock(() => ui.requestRender());
const tracker = new WorkingOutputTracker();
const indicator = new WorkingStatusIndicator(ui, "Working...", undefined, "step", tracker, () =>
spinner.currentToolName(),
);
const pendingTools = new Map<string, ToolExecutionComponent>();
const chatContainer = new Container();
const ctx = {
isInitialized: true,
presentation: "step",
footer: { invalidate: vi.fn() },
pendingTools,
chatContainer,
stepSpinner: spinner,
workingOutputTracker: tracker,
activeStatusIndicator: indicator,
workingVisible: true,
redraw: { requestRender: () => ui.requestRender() },
} as unknown as RuntimeContext;
return {
pendingTools,
spinner,
tracker,
text: () => stripTerminalSequences(indicator.render(100).join("\n")),
async start(toolName: string, toolCallId: string) {
const args = { path: "fixture.txt", command: "printf done" };
const component = new ToolExecutionComponent(
toolName,
toolCallId,
args,
{ presentation: "step", spinner, showImages: false },
undefined,
ui,
process.cwd(),
);
pendingTools.set(toolCallId, component);
chatContainer.addChild(component);
await handleSessionEvent(ctx, { type: "tool_execution_start", toolName, toolCallId, args });
},
end(toolName: string, toolCallId: string, isError = false) {
return handleSessionEvent(ctx, {
type: "tool_execution_end",
toolName,
toolCallId,
result: { content: [{ type: "text", text: isError ? "failed" : "done" }] },
isError,
});
},
dispose() {
indicator.dispose();
spinner.dispose();
},
};
}

describe("working status after tool completion", () => {
beforeEach(() => {
initTheme("step-blue");
vi.useFakeTimers();
vi.setSystemTime(0);
});
afterEach(() => vi.useRealTimers());

it.each([
["run_command", "Running...", false],
["read_file", "Reading...", false],
["edit_file", "Editing...", false],
["run_command", "Running...", true],
["read_file", "Reading...", true],
["edit_file", "Editing...", true],
] as const)("stops naming completed %s (%s, error=%s)", async (toolName, verb, isError) => {
const view = createView();
try {
await view.start(toolName, "call-1");
expect(view.text()).toContain(verb);
vi.advanceTimersByTime(10);
await view.end(toolName, "call-1", isError);

expect(view.pendingTools.size).toBe(0);
expect(view.spinner.currentToolName()).toBeUndefined();
expect(view.text()).toContain("Working...");
expect(view.text()).not.toContain(verb);
// No further model output arrives during this simulated minute.
vi.advanceTimersByTime(60_000);
expect(view.text()).toContain("Working... (1m");
expect(view.text()).not.toContain(verb);
} finally {
view.dispose();
}
});

it("names the remaining parallel tool as soon as the newest one completes", async () => {
const view = createView();
try {
await view.start("run_command", "long-command");
await view.start("read_file", "short-read");
expect(view.text()).toContain("Reading...");
await view.end("read_file", "short-read");
expect(view.pendingTools.size).toBe(1);
expect(view.text()).toContain("Running...");

vi.advanceTimersByTime(60_000);
expect(view.text()).toContain("Running...");
await view.end("run_command", "long-command");
expect(view.text()).toContain("Working...");
} finally {
view.dispose();
}
});
});
Loading