@zvada/agent-server 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.12
4
+
5
+ - Report a Claude turn whose API call failed as an error. Claude Code ends it
6
+ as a `success` result flagged `is_error` (a rejected key, an exhausted quota,
7
+ an overloaded provider), with the failure as its text; it was reported as a
8
+ completed `end_turn` whose answer quoted the failure. The failure's text now
9
+ classifies the error (a 401 is `auth`).
10
+
11
+ ## 0.3.11
12
+
13
+ - Report a Claude turn stopped while a tool runs as `cancelled`. Claude Code
14
+ ends it with `error_during_execution` and the aborted step's stop reason
15
+ (`tool_use`), which was reported as an error; an interrupted turn that did
16
+ not succeed is now a cancellation whatever its stop reason.
17
+ - Confirm a Claude cancel at once while the CLI is still starting: terminate
18
+ the process instead of waiting for it to acknowledge an interrupt. A slow
19
+ sandbox startup reported `confirmed: false` after the 2-second interrupt
20
+ timeout, and a prompt the CLI had not yet dequeued could survive the
21
+ interrupt and run anyway. The next turn starts a new process.
22
+
3
23
  ## 0.3.10
4
24
 
5
25
  - Support Codex app-server per-turn stdio and Streamable HTTP MCP configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -97,6 +97,8 @@ type ClaudeMessage =
97
97
  turn_cost_usd?: number | null;
98
98
  stop_reason?: string | null;
99
99
  is_error?: boolean;
100
+ /** On an `is_error` success, the API failure's text. */
101
+ result?: string;
100
102
  };
101
103
 
102
104
  type BlockKind = "text" | "reasoning" | "tool";
@@ -240,7 +242,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
240
242
  private error?: string;
241
243
  private interrupted = false;
242
244
  private execFailure = false;
243
- private sawResult = false;
245
+ private succeeded = false;
244
246
 
245
247
  constructor(ctx: { sessionId: string }) {
246
248
  this.ctx = { sessionId: ctx.sessionId, messageId: "" };
@@ -281,17 +283,19 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
281
283
  }
282
284
 
283
285
  finish(): TransformResult {
284
- // error_during_execution + null stop_reason is cancellation ONLY when the
285
- // agent confirmed an interrupt; otherwise it is a real failure (a silent
286
- // "cancelled" here is how a bad resume id used to vanish without a trace).
287
- // A confirmed interrupt with NO result at all (aborted before the SDK
288
- // emitted one) is also a cancellation, not a completed turn.
289
- const cancelled = this.interrupted && (this.execFailure || !this.sawResult);
290
- const error =
291
- this.error ??
292
- (this.execFailure && !this.interrupted
293
- ? "Claude turn failed during execution (error_during_execution — e.g. an invalid resumeSessionId fails this way)"
294
- : undefined);
286
+ // An interrupted turn that did not succeed was cancelled. Claude ends it with
287
+ // error_during_execution and the stop reason of the step it aborted (null
288
+ // while streaming, "tool_use" while a tool runs), or with no result when
289
+ // the process was stopped first. Without an interrupt, error_during_execution
290
+ // is a real failure (a silent "cancelled" here is how a bad resume id used
291
+ // to vanish without a trace).
292
+ const cancelled = this.interrupted && !this.succeeded;
293
+ const error = cancelled
294
+ ? undefined
295
+ : (this.error ??
296
+ (this.execFailure
297
+ ? "Claude turn failed during execution (error_during_execution — e.g. an invalid resumeSessionId fails this way)"
298
+ : undefined));
295
299
  return {
296
300
  usage: this.usage,
297
301
  ...(this.reportedModels.size && { reportedModels: [...this.reportedModels] }),
@@ -503,7 +507,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
503
507
  }
504
508
 
505
509
  private captureResult(msg: Extract<ClaudeMessage, { type: "result" }>): AdapterEvent[] {
506
- this.sawResult = true;
510
+ this.succeeded = msg.subtype === "success" && !msg.is_error;
507
511
  if (msg.usage) {
508
512
  const creation = msg.usage.cache_creation;
509
513
  this.usage = {
@@ -528,13 +532,19 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
528
532
  if (msg.subtype === "error_during_execution" && msg.stop_reason === null) {
529
533
  // Ambiguous shape: an interrupt AND an execution failure (e.g. a bad
530
534
  // resume id) both surface this way. The agent's `turn_interrupted`
531
- // marker (which arrives after this message) disambiguates in finish().
535
+ // marker (which arrives after this message) disambiguates in finish(),
536
+ // as it does for an interrupt that aborted a tool (stop reason "tool_use").
532
537
  this.execFailure = true;
533
538
  } else if (msg.subtype === "error_max_turns") {
534
539
  // Hitting the turn budget is a stop condition, not a failure.
535
540
  } else {
536
541
  this.error = `Claude turn ended: ${msg.subtype}`;
537
542
  }
543
+ } else if (msg.is_error) {
544
+ // A failed API call (a rejected key, an exhausted quota, an overloaded
545
+ // provider) ends the turn as a "success" whose text is the error. The
546
+ // text names the failure, so the runtime classifies it (401 -> auth).
547
+ this.error = msg.result || "Claude turn ended with an API error";
538
548
  }
539
549
  // Final authoritative gauge: the result knows the context-window size.
540
550
  if (!msg.usage) return [];
@@ -122,6 +122,8 @@ export class ClaudeGeneratorSession {
122
122
  private readonly failedMcpServers = new Set<string>();
123
123
  private processInvalidated = false;
124
124
  private nativeSessionId: string | null = null;
125
+ /** Set by the CLI's first message; until then it has begun no turn. */
126
+ private cliStarted = false;
125
127
  private startupError: unknown;
126
128
  /** SDK cost is cumulative within this query; token usage is already per turn. */
127
129
  private cumulativeCost: number | undefined = 0;
@@ -375,9 +377,17 @@ export class ClaudeGeneratorSession {
375
377
  * SDK acknowledged it; false when the round-trip timed out or failed — the
376
378
  * subprocess may still be running its turn and the caller must report that
377
379
  * honestly instead of assuming a clean cancel.
380
+ *
381
+ * A session still starting, or whose CLI has sent nothing yet, is terminated
382
+ * instead: it has begun no turn, an interrupt would wait out its startup, and
383
+ * a prompt the CLI has not dequeued survives an interrupt and runs anyway.
378
384
  */
379
385
  async interruptTurn(timeoutMs = 2000): Promise<boolean> {
380
- if ((this.state !== "busy" && this.state !== "starting") || !this.query) return true;
386
+ if (this.state === "idle" || this.state === "terminated") return true;
387
+ if (this.state === "starting" || !this.cliStarted || !this.query) {
388
+ await this.terminate();
389
+ return true;
390
+ }
381
391
  let timer: ReturnType<typeof setTimeout> | undefined;
382
392
  try {
383
393
  await Promise.race([
@@ -422,6 +432,7 @@ export class ClaudeGeneratorSession {
422
432
  if (!this.query) return;
423
433
  try {
424
434
  for await (const event of this.query) {
435
+ this.cliStarted = true;
425
436
  if (event.type === "system" && event.subtype === "init" && event.session_id) {
426
437
  this.nativeSessionId = event.session_id;
427
438
  }