Skip to content
Draft
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
6 changes: 5 additions & 1 deletion apps/cli/src/utils/run-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,28 +94,32 @@ export const runHeadless = (options: HeadlessRunOptions) =>
for (const event of executed.events) {
if (seenEvents.has(event.id)) continue;
seenEvents.add(event.id);
lastOutputAt = Date.now();
switch (event._tag) {
case "RunStarted":
lastOutputAt = Date.now();
ciReporter.planTitle(event.plan.title, Option.getOrUndefined(event.plan.baseUrl));
break;
case "StepStarted":
lastOutputAt = Date.now();
ciReporter.stepStarted(event.title);
break;
case "StepCompleted": {
lastOutputAt = Date.now();
const step = executed.steps.find((step) => step.id === event.stepId);
const elapsed = step ? getStepElapsedMs(step) : undefined;
ciReporter.stepCompleted(event.summary, elapsed);
break;
}
case "StepFailed": {
lastOutputAt = Date.now();
const failedStep = executed.steps.find((step) => step.id === event.stepId);
const failedTitle = failedStep?.title ?? event.stepId;
const failedElapsed = failedStep ? getStepElapsedMs(failedStep) : undefined;
ciReporter.stepFailed(failedTitle, event.message, failedElapsed);
break;
}
case "StepSkipped": {
lastOutputAt = Date.now();
const skippedStep = executed.steps.find((step) => step.id === event.stepId);
const skippedTitle = skippedStep?.title ?? event.stepId;
ciReporter.stepSkipped(skippedTitle, event.reason);
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/tests/ci-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,5 +313,19 @@ describe("createCiReporter", () => {
expect(output).toContain("Still running");
expect(output).toContain("2m");
});

it("remains the only visible progress signal when no step events are printed", () => {
const reporter = createCiReporter({
version: "1.0.0",
agent: "claude",
timeoutMs: undefined,
isGitHubActions: false,
});
reporter.heartbeat(120_000);
const output = stderrText();
expect(output).toContain("Still running");
expect(output).not.toContain("STEP_START|");
expect(output).not.toContain("RUN_COMPLETED|");
});
});
});
195 changes: 165 additions & 30 deletions packages/shared/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,14 +594,16 @@ const serializeToolResult = (value: unknown): string => {
};

const parseMarker = (line: string): ExecutionEvent | undefined => {
const pipeIndex = line.indexOf("|");
const normalizedLine = line.endsWith("\r") ? line.slice(0, -1) : line;
const pipeIndex = normalizedLine.indexOf("|");
if (pipeIndex === -1) return undefined;

const marker = line.slice(0, pipeIndex);
const rest = line.slice(pipeIndex + 1);
const marker = normalizedLine.slice(0, pipeIndex);
const rest = normalizedLine.slice(pipeIndex + 1);
const secondPipeIndex = rest.indexOf("|");
const first = secondPipeIndex === -1 ? rest : rest.slice(0, secondPipeIndex);
const second = secondPipeIndex === -1 ? "" : rest.slice(secondPipeIndex + 1);
if (secondPipeIndex === -1) return undefined;
const first = rest.slice(0, secondPipeIndex);
const second = rest.slice(secondPipeIndex + 1);

if (marker === "STEP_START") {
return new StepStarted({ stepId: StepId.makeUnsafe(first), title: second });
Expand Down Expand Up @@ -669,6 +671,135 @@ export class Update extends Schema.Class<Update>("@supervisor/Update")({
receivedAt: Schema.DateTimeUtc,
}) {}

interface ProcessedTextBlock {
readonly events: readonly ExecutionEvent[];
readonly markers: readonly ExecutionEvent[];
}

interface MarkerMatch {
readonly marker: ExecutionEvent;
readonly start: number;
readonly end: number;
}

const buildTextEvent = (
textEventTag: "AgentText" | "AgentThinking",
text: string,
): AgentText | AgentThinking =>
textEventTag === "AgentText" ? new AgentText({ text }) : new AgentThinking({ text });

const MARKER_PREFIXES = [
"STEP_START|",
"STEP_DONE|",
"ASSERTION_FAILED|",
"STEP_SKIPPED|",
"RUN_COMPLETED|",
] as const;

const markerBoundaryAfter = (line: string, start: number): number => {
let earliestBoundary = line.length;
for (const prefix of MARKER_PREFIXES) {
const nextBoundary = line.indexOf(prefix, start + 1);
if (nextBoundary !== -1 && nextBoundary < earliestBoundary) {
earliestBoundary = nextBoundary;
}
}
return earliestBoundary;
};

const findMarkerInLine = (line: string): MarkerMatch | undefined => {
for (const prefix of MARKER_PREFIXES) {
const start = line.indexOf(prefix);
if (start === -1) continue;
const end = markerBoundaryAfter(line, start);
const marker = parseMarker(line.slice(start, end));
if (marker === undefined) continue;
return {
marker,
start,
end,
};
}
return undefined;
};

const processTextBlock = (
textEventTag: "AgentText" | "AgentThinking",
text: string,
includeTrailingPartialLine: boolean,
): ProcessedTextBlock => {
const events: ExecutionEvent[] = [];
const markers: ExecutionEvent[] = [];
let bufferedText = "";

const flushBufferedText = () => {
if (bufferedText.length === 0) return;
events.push(buildTextEvent(textEventTag, bufferedText));
bufferedText = "";
};

const processLine = (line: string, hasTrailingNewline: boolean) => {
const markerMatch = findMarkerInLine(line);
if (markerMatch !== undefined) {
const prefixText = line.slice(0, markerMatch.start);
if (prefixText.length > 0) {
bufferedText += prefixText;
if (hasTrailingNewline) {
bufferedText += "\n";
}
}
flushBufferedText();
events.push(markerMatch.marker);
markers.push(markerMatch.marker);
const suffixText = line.slice(markerMatch.end);
if (suffixText.length > 0) {
processLine(suffixText, hasTrailingNewline);
}
return;
}
bufferedText += line;
if (hasTrailingNewline) {
bufferedText += "\n";
}
};

let completeLines: readonly string[] = [];
let trailingLine = "";

if (text.endsWith("\n")) {
completeLines = text.slice(0, -1).split("\n");
} else {
const parts = text.split("\n");
trailingLine = parts.pop() ?? "";
completeLines = parts;
}

for (const completeLine of completeLines) {
processLine(completeLine, true);
}

if (includeTrailingPartialLine) {
processLine(trailingLine, false);
} else {
bufferedText += trailingLine;
}

flushBufferedText();

return { events, markers };
};

const applyMarkersToPlan = (
executed: ExecutedTestPlan,
markers: readonly ExecutionEvent[],
): ExecutedTestPlan => {
let result = executed;
for (const marker of markers) {
result = result.applyMarker(marker);
}
return result;
};

export class PullRequest extends Schema.Class<PullRequest>("@supervisor/PullRequest")({
number: Schema.Number,
url: Schema.String,
Expand Down Expand Up @@ -753,38 +884,48 @@ export class ExecutedTestPlan extends TestPlan.extend<ExecutedTestPlan>(
if (update.content.type !== "text" || update.content.text === undefined) return this;
const lastEvent = this.events.at(-1);
if (lastEvent?._tag === "AgentThinking") {
return new ExecutedTestPlan({
const processed = processTextBlock(
"AgentThinking",
lastEvent.text + update.content.text,
false,
);
const withEvents = new ExecutedTestPlan({
...this,
events: [
...this.events.slice(0, -1),
new AgentThinking({ text: lastEvent.text + update.content.text }),
],
events: [...this.events.slice(0, -1), ...processed.events],
});
return applyMarkersToPlan(withEvents, processed.markers);
}
const base = this.finalizeTextBlock();
return new ExecutedTestPlan({
const processed = processTextBlock("AgentThinking", update.content.text, false);
const withEvents = new ExecutedTestPlan({
...base,
events: [...base.events, new AgentThinking({ text: update.content.text })],
events: [...base.events, ...processed.events],
});
return applyMarkersToPlan(withEvents, processed.markers);
}

if (update.sessionUpdate === "agent_message_chunk") {
if (update.content.type !== "text" || update.content.text === undefined) return this;
const lastEvent = this.events.at(-1);
if (lastEvent?._tag === "AgentText") {
return new ExecutedTestPlan({
const processed = processTextBlock(
"AgentText",
lastEvent.text + update.content.text,
false,
);
const withEvents = new ExecutedTestPlan({
...this,
events: [
...this.events.slice(0, -1),
new AgentText({ text: lastEvent.text + update.content.text }),
],
events: [...this.events.slice(0, -1), ...processed.events],
});
return applyMarkersToPlan(withEvents, processed.markers);
}
const base = this.finalizeTextBlock();
return new ExecutedTestPlan({
const processed = processTextBlock("AgentText", update.content.text, false);
const withEvents = new ExecutedTestPlan({
...base,
events: [...base.events, new AgentText({ text: update.content.text })],
events: [...base.events, ...processed.events],
});
return applyMarkersToPlan(withEvents, processed.markers);
}

if (update.sessionUpdate === "tool_call") {
Expand Down Expand Up @@ -859,19 +1000,13 @@ export class ExecutedTestPlan extends TestPlan.extend<ExecutedTestPlan>(
finalizeTextBlock(): ExecutedTestPlan {
const lastEvent = this.events.at(-1);
if (lastEvent?._tag !== "AgentText" && lastEvent?._tag !== "AgentThinking") return this;
const foundMarkers = lastEvent.text
.split("\n")
.map(parseMarker)
.filter(Predicate.isNotUndefined);
if (foundMarkers.length === 0) return this;
let result: ExecutedTestPlan = new ExecutedTestPlan({
const processed = processTextBlock(lastEvent._tag, lastEvent.text, true);
if (processed.markers.length === 0) return this;
const withEvents = new ExecutedTestPlan({
...this,
events: [...this.events, ...foundMarkers],
events: [...this.events.slice(0, -1), ...processed.events],
});
for (const marker of foundMarkers) {
result = result.applyMarker(marker);
}
return result;
return applyMarkersToPlan(withEvents, processed.markers);
}

applyMarker(marker: ExecutionEvent): ExecutedTestPlan {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const buildExecutionPrompt = (options: ExecutionPromptOptions): string =>
"",
"1. open — Launch a browser and navigate to a URL.",
"2. playwright — Execute Playwright code in Node. Globals: page (Page), context (BrowserContext), browser (Browser), ref(id) (resolves a snapshot ref like 'e4' to a Playwright Locator). Supports await. Return a value to get it back as JSON.",
" IMPORTANT: playwright snippets run as plain JavaScript, not TypeScript. Never use TS-only syntax such as `as`, type annotations, interfaces, enums, or generics inside a playwright call.",
"3. screenshot — Capture page state. Set mode: 'snapshot' (ARIA accessibility tree, default and preferred), 'screenshot' (PNG image), or 'annotated' (PNG with numbered labels on interactive elements).",
"4. console_logs — Get browser console messages. Filter by type ('error', 'warning', 'log'). Use after navigation or interactions to catch errors.",
"5. network_requests — Get captured network requests. Filter by method, URL substring, or resource type ('xhr', 'fetch', 'document').",
Expand Down
Loading
Loading