GuideAI-fixable errorsTypeScript transpilerMCP agent

An automation testing tool for desktop applications, shaped for the model that wrote the test

Most desktop test runners hand you a stack trace when a test fails to compile. Terminator hands the failing test back to whatever AI wrote it as a JSON object with error_type, error_line, fix, and a recovery action. Four recovery variants. Fourteen TypeScript-detection regexes. A condition parser that strips smart quotes before evaluating. The pieces of a desktop testing tool designed to be driven by an LLM that may have written the test five seconds ago.

M
Matthew Diakonov
13 min read
4.9from Open-source, MIT
Compile errors return as JSON with error_type, can_fix, error_line, fix, and recovery
Four typed RecoveryAction variants: FixCode, UseJavaScript, InstallTool, Retry
is_typescript_code() auto-routes plain JS and TypeScript with 14 regex patterns
expression_eval.rs strips four smart-quote variants and three Unicode space variants before parsing

The shape an AI sees when a test fails

When the agent calls execute_sequence with a TypeScript script that does not compile, the tool error comes back as a structured payload. Every field is there for a reason. The MCP client (Claude Code, Cursor, Windsurf) reads it, patches the source line, and re-submits.

tool error returned to MCP client
0RecoveryAction variants
0TS detection regexes
0Unicode chars normalized
0Lines in transpiler.rs

Four recovery variants, picked at the agent

The error message is not the whole contract. The recovery field tells the AI what to do next. Four variants, each one mapped to a different failure shape. The dispatch happens once, on the Rust side, after the transpiler categorizes the failure. The client never has to invent its own recovery taxonomy.

crates/terminator-mcp-agent/src/transpiler.rs

The recovery that embeds its own instruction

Three of the four variants are categories. One of them, when the transpiler is missing and the test contains TypeScript-only syntax, is a literal sentence. The agent receives this string as part of the JSON. There is no ambiguity about what to do.

crates/terminator-mcp-agent/src/transpiler.rs

How the runtime decides if a script is TypeScript

The author does not flag it. There is no language: ts field. Fourteen regex patterns look for TypeScript-only syntax; one match flips the routing. A one-line desktop.click() short-circuits the transpiler and runs as plain JavaScript. A file with interface and :string goes through bun or esbuild. The same call signature handles both.

crates/terminator-mcp-agent/src/transpiler.rs

Watch a failed test repair itself

One of the most common LLM mistakes is omitting the colon between a variable name and a TypeScript type. Here is what the agent log looks like when that happens, and what the AI does next.

execute_sequence -> compile error -> structured fix -> success
4 recovery variants

When the test fails, the failure is itself the prompt.

transpiler.rs::TranspileError::into_mcp_error

The condition parser strips smart quotes before evaluating

A test gates a step on a condition like contains(env.locale, 'en-US') or runtime.attempts >= 3. When that string comes out of a chat client or a doc, the quotes are typically U+2018, U+2019, U+201C, U+201D. Most parsers reject them. This one normalizes them to ASCII before parsing, along with three Unicode space variants. The condition runs.

crates/terminator-mcp-agent/src/expression_eval.rs

What flows through the transpiler

Anything the agent submits as a script. AI-written TypeScript. YAML pasted with smart quotes intact. Hand-edited JavaScript with no types. A mixed module that starts with interface and ends with a vanilla call expression. Each input is routed to one of four outcomes.

Inputs into transpile(), outcomes the agent receives

AI-written .ts
Pasted YAML
Hand-edited JS
Mixed module
transpile()
Ok(TranspileResult)
RecoveryAction::FixCode
RecoveryAction::UseJavaScript
RecoveryAction::InstallTool

The roundtrip the AI sees on every call

From the moment the agent submits a script to the moment it gets a structured tool result, the path runs through three boundaries: MCP server, transpiler, runtime. Errors cross the same boundaries in reverse, picking up structure as they go.

execute_sequence -> compile -> retry

AI agentMCP serverTranspilerBun runtimeexecute_sequence({ script: '<TypeScript>' })transpile(script, NodeJs) -> is_typescript_code: truebun build script.ts --target node --no-bundlestderr: 'script.ts:1:8: error: Expected ...'Err(TranspileError { kind: SyntaxError, fix, recovery: FixCode })McpError { error_type, can_fix, location, error_line, fix, recovery }execute_sequence({ script: '<repaired TypeScript>' })transpile(script, NodeJs)bun buildJS outputOk(TranspileResult { code, was_typescript: true })Step completed.

Six steps the runtime takes for every script

None of these are optional, none are configurable from the test. They run on every execute_sequence call, in this order.

1

The agent submits a script as a tool argument

execute_sequence accepts a script field on the MCP tool call. The script can be TypeScript, plain JavaScript, or anything in between. The author does not declare which one. The Rust agent decides at the next step.

2

is_typescript_code() runs 14 regex matches

transpiler.rs lines 230-274. The patterns target type annotations, interface and type aliases, generic parameters, as casts, non-null assertions, parameter and return type annotations, readonly, public/private/protected, enum, namespace, declare, and import type. One match flips the routing flag.

3

Plain JS short-circuits, TypeScript runs through a transpiler

If the detector returns false, transpile() returns Ok(TranspileResult { code: script, was_typescript: false }) without writing a temp file. If it returns true, the agent picks bun if find_executable('bun') succeeds (with a bundled bun next to the binary as the first probe), otherwise esbuild via npx, otherwise RecoveryAction::InstallTool.

4

On compile failure, parse the output into a TranspileError

parse_transpilation_error in transpiler.rs uses three pre-compiled regexes (esbuild file:line:col, bun stack frame, TypeScript TS#### codes). It fills line, column, error_line, message, and a fix string when it can derive one. The TranspileErrorKind goes onto the error so the recovery dispatcher knows which path to write.

5

into_mcp_error() emits the structured payload

The error becomes McpError::invalid_params with the JSON shape the AI agent reads. error_type and can_fix are always present. location, error_line, fix are conditional. recovery is one of fix_code, use_javascript (which embeds an instruction string), install_tool (with tool name and URL), or retry.

6

The agent reads can_fix and either retries or escalates

When can_fix is true, the typical pattern in Claude Code or Cursor is: read fix, splice into the script around location.line, call execute_sequence again. When can_fix is false (system_error, missing_tool with no fallback), the agent surfaces the message to the human. No stack trace ever appears in the tool result.

How this stacks up against a typical record-and-playback tool

The relevant axis is not whether a tool can record clicks; every tool can. The relevant axis is what the runner returns when something goes wrong, because that is what an AI driving the loop actually consumes.

FeatureTypical desktop test toolTerminator
Compile error shapePlain text stack trace dumped to stderr. The LLM has to parse it back into structure on its own.Structured JSON: error_type, can_fix, location.line, location.column, error_line, fix, recovery. Built by into_mcp_error() in transpiler.rs.
What to do next, if anythingImplicit. The model guesses retry, rewrite, or escalate.Explicit recovery field with four typed values: fix_code, use_javascript, install_tool, retry. RecoveryAction enum, lines 81-91.
Type-aware testsEither the runner is JavaScript-only (you lose types) or it is TypeScript-only (you lose flexibility).is_typescript_code() runs 14 regex patterns on every script. Plain JS skips transpilation, TS goes through bun or esbuild. The test author can write either, the same call works.
Smart quotes from copy-pasteCondition parsers reject U+2018, U+2019, U+201C, U+201D and fail with a cryptic 'unexpected character' message.expression_eval.rs::normalize_expression replaces all four smart-quote variants and three non-breaking space variants with ASCII before evaluating. The condition runs.
Fallback path when bun is missingEither an install error or a hard requirement that bun be on PATH.Bun preferred (find_executable checks bundled location first, then PATH), esbuild via npx as the second tier, RecoveryAction::InstallTool with a download URL as the last tier.
Test licensePer-seat or per-runner. Often gated behind a sales call.MIT. transpiler.rs is 950 lines, expression_eval.rs is 458 lines. Fork it.

Eight things this tool actually lets you do

Not feature pills, not capability claims. Concrete behaviors you can verify in the source by grepping the file paths listed under each one in the FAQ.

  • Hand the agent a TypeScript test that does not compile and watch it fix itself on the next call without your intervention
  • Mix typed and untyped code in the same test file and have the runtime route it correctly without an explicit flag
  • Accept a step gating expression copied out of a doc with smart quotes intact and still evaluate it
  • Get a structured 'install bun' instruction back when the runtime is missing instead of a confusing PATH error
  • Drop in expression_eval.rs into your own test runner if you want the smart-quote handling without the rest of the stack
  • Trace any failure to a 1- or 2-sentence message plus the offending source line, never a multi-frame stack
  • Run the same script through plain bun locally for human debugging and through the agent for AI-driven runs without a code change
  • Read all 1408 lines of transpiler.rs + expression_eval.rs and prove every claim on this page in under thirty minutes

See an AI fix its own desktop test in real time

Twenty minutes with the team, walking through transpiler.rs, the recovery dispatch, and a real failed run repaired without a human in the loop.

Questions about an AI-shaped desktop test runner

Why does a desktop test runner need an LLM-shaped error contract at all? Tests are written by humans.

Some are. The interesting case is the ones that are not. When a developer hands an AI coding assistant the prompt 'write a Terminator test that opens Notepad, types Hello, screenshots the result, and asserts the title contains Hello.txt', the assistant generates a TypeScript file in seconds and submits it to the agent. If that file does not compile, the assistant has two choices: read a stack trace, infer the problem, and retry, or surface the failure to the human. Stack traces are bad for the first option because they are designed for human readers (lots of context, fuzzy framing) and they vary between bun and esbuild and node. Terminator pre-shapes the error so the assistant gets exactly what it needs to retry: error_type, can_fix, location, error_line, fix, recovery. That contract is at transpiler.rs lines 156-196 in into_mcp_error. The point is not that humans can not write tests; the point is that an agent driving this loop is now a normal authoring path, and the tool fits that path natively.

What is in the recovery field?

Four typed values defined as RecoveryAction at transpiler.rs lines 81-91. fix_code: the AI should rewrite the source based on the message. use_javascript: the AI should remove TypeScript syntax and resubmit, with the literal instruction 'Rewrite without TypeScript syntax. Remove: type annotations (: string), interfaces, generics (<T>), 'as' casts, enum, namespace.' embedded in the JSON at lines 184-186. install_tool: the runtime is missing, the JSON includes a tool name and a download URL, the AI should pause and ask the human. retry: a transient system error, the AI should call again. The dispatch happens once, on the agent side, after parse_transpilation_error categorizes the failure. The client never has to invent its own recovery taxonomy.

How does the runner decide whether a script is TypeScript without a flag from the caller?

is_typescript_code() at transpiler.rs lines 230-274 walks 14 regex patterns over the script. The patterns hit type annotations on variables, function parameters, and return values; interface, type, and enum declarations; generic parameters (<T> and X<Y>); as casts; non-null assertions; readonly; the public, private, protected modifiers; namespace; declare; and import type. One match returns true and the script is sent to bun or esbuild. Zero matches returns false and the script runs as JavaScript with no transpile step at all. The benefit is that a one-liner script that does desktop.click() does not pay the bun startup tax, and a heavy TypeScript file with interfaces does not have to be hand-flagged. The detector is opportunistic, not strict: a JavaScript file that contains the substring 'as string' inside a comment will be transpiled. That is the cost of avoiding a flag.

What does the smart-quote handling actually do, and why does it matter for a desktop testing tool?

expression_eval.rs::normalize_expression at lines 6-15 calls .replace() on the input string for U+2018 and U+2019 (left and right single curly quotes), U+201C and U+201D (left and right double curly quotes), backtick (mapped to single quote so an LLM that wraps strings in backticks still parses), and U+00A0, U+2009, U+202F (non-breaking space, thin space, narrow no-break space). It runs before the expression goes into evaluate_internal at line 33. The reason this matters: a step in a Terminator workflow can be gated on a condition like contains(env.locale, 'en-US') or runtime.attempts >= 3. When that string comes from a chat client, a Notion doc, or a copy-paste out of a doc viewer, the quotes are usually curly. Other condition parsers reject the input. This one accepts it. For an AI-authored test the difference is concrete: contains(value, 'sandbox') with curly quotes around 'sandbox' compiles, evaluates, and gates the step.

Where does this fit relative to a record-and-playback tool or a no-code IDE?

Different layer. A record-and-playback IDE solves authoring: you click around, the tool emits a script. Terminator does not have a recorder shipped with the framework today; it expects either a developer or an AI agent to write the script. The transpiler and condition parser exist because the script can come from anywhere, including a model that may have written it badly. So the comparison is not 'Terminator vs. TestComplete' on UI features; it is 'Terminator vs. anything that wants to be driven by an AI coding assistant', and the relevant feature surface is what the runner returns when something goes wrong. Most desktop testing tools were built before LLM-driven authoring existed. Their error surfaces still assume a human will read them. Terminator's does not.

Does any of this matter if I am running tests by hand?

Some of it. The fix string in TranspileError is short and actionable for humans too: 'Add : between variable name and type: const x: string'. The error_line gives you the exact source the parser tripped on without scrolling through stack frames. parse_transpilation_error normalizes esbuild and bun output into one shape, so a developer who switches tools does not see different formats. The smart-quote normalization helps anyone copying expressions out of an editor that auto-corrects them. The pieces that matter most for AI-driven runs (RecoveryAction routing, can_fix, the use_javascript instruction string) are not relevant if no agent is reading the tool result. They do not get in the way either.

Can I see the error contract for a real failure end to end?

Yes. Clone https://github.com/mediar-ai/terminator, install bun, and run cargo test -p terminator-mcp-agent transpiler. The tests at the bottom of transpiler.rs round-trip syntax errors and type errors through parse_transpilation_error and assert the JSON shape. If you want to see it across the wire, attach any MCP client to the agent, call execute_sequence with a deliberately broken TypeScript snippet, and read the structured error in the tool result. Concretely: the script 'const x string = 1;' produces { error_type: 'syntax_error', can_fix: true, location: { line: 1, column: 8 }, error_line: "const x string = 1;", recovery: 'fix_code' }. The path from your input to that JSON is two functions: transpile() and TranspileError::into_mcp_error().

How does this differ from the existing event_pipe.rs telemetry channel?

Different phase of the run. event_pipe.rs handles in-flight telemetry: the test is compiling and executing, and it streams progress and step events to the MCP client. transpiler.rs handles the gate before the test ever runs. If the script does not compile, no step events fire, no pipe is opened. The transpiler error is the result of the tool call. The event pipe is the side channel during the tool call. Both speak structured JSON to the same client; both are designed for an AI consumer; they live at different points in the lifecycle.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.