@cabane/companion 0.6.37 → 0.6.38

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/dist/cli.js CHANGED
@@ -3177,6 +3177,28 @@ function normalizeTurnResultReason(reason) {
3177
3177
  }
3178
3178
  }
3179
3179
 
3180
+ // packages/agent-runtime/src/empty-result.ts
3181
+ function normalizeCommitText(text) {
3182
+ return text.trim();
3183
+ }
3184
+ function isContentBearingEvent(event) {
3185
+ switch (event.type) {
3186
+ case "text":
3187
+ return normalizeCommitText(event.body) !== "";
3188
+ case "thinking":
3189
+ return normalizeCommitText(event.text) !== "";
3190
+ case "tool":
3191
+ return true;
3192
+ case "session":
3193
+ case "result":
3194
+ return false;
3195
+ }
3196
+ }
3197
+ function classifyEmptyResult(input) {
3198
+ if (!input.ok || input.contentBearingEvents > 0) return null;
3199
+ return input.usage?.inputTokens === 0 && input.usage.outputTokens === 0 ? "empty_result" : "empty_result_unverified";
3200
+ }
3201
+
3180
3202
  // packages/agent-runtime/src/turn-request.ts
3181
3203
  import { z as z8 } from "zod";
3182
3204
  var contentBlockSchema = z8.discriminatedUnion("type", [
@@ -3498,7 +3520,7 @@ var TurnPump = class {
3498
3520
  this.sink = {
3499
3521
  onAssistantText: async (evt) => {
3500
3522
  if (opts.signal.aborted) return;
3501
- const body = evt.text.trim();
3523
+ const body = normalizeCommitText(evt.text);
3502
3524
  if (!body) return;
3503
3525
  const kind = evt.final ? "final" : "progress";
3504
3526
  const seq = opts.nextSeq();
@@ -3529,7 +3551,7 @@ var TurnPump = class {
3529
3551
  },
3530
3552
  onThinking: async (evt) => {
3531
3553
  if (opts.signal.aborted) return;
3532
- const trimmed = evt.text.trim();
3554
+ const trimmed = normalizeCommitText(evt.text);
3533
3555
  if (!trimmed) return;
3534
3556
  const text = truncate(trimmed, THINKING_TEXT_MAX_CHARS);
3535
3557
  const seq = opts.nextSeq();
@@ -4207,6 +4229,19 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
4207
4229
  { type: "result", ok: false, reason: "result_error:error_max_turns" }
4208
4230
  ]
4209
4231
  },
4232
+ {
4233
+ // CT1144: the empty-runtime anomaly's exact captured trace — `system/init`
4234
+ // then a successful `result`, with nothing in between. Distinct from
4235
+ // `empty-final` below: THAT turn did work (a tool ran) and just didn't speak;
4236
+ // this one produced nothing at all. The adapter's job is unchanged either way
4237
+ // — report faithfully what the runtime said — so it must still emit
4238
+ // `result{ok:true}` here; the host is what decides that a success with no
4239
+ // content-bearing event is a failed turn.
4240
+ name: "zero-content success (empty runtime result)",
4241
+ request: makeRequest(),
4242
+ nativeStream: [init("s1"), resultSuccess("s1")],
4243
+ expected: [sessionEvent("s1"), { type: "result", ok: true }]
4244
+ },
4210
4245
  {
4211
4246
  // Empty-final: a clean turn that ended on a tool call with no closing text.
4212
4247
  // The adapter emits NO final text — empty-final promotion is host/pump
@@ -5243,6 +5278,18 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5243
5278
  ],
5244
5279
  expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "auth_expired" }]
5245
5280
  },
5281
+ {
5282
+ // CT1144: the same zero-content success the claude-code suite pins, in
5283
+ // opencode's vocabulary — the session goes idle having emitted no part at
5284
+ // all. No opencode sample of the anomaly has been seen in the wild; the
5285
+ // fixture exists because the host's classification is runtime-neutral, so a
5286
+ // runtime that CAN produce this envelope must be shown producing it in the
5287
+ // shape the classifier reads.
5288
+ name: "zero-content success (empty runtime result)",
5289
+ request: makeRequest2(),
5290
+ nativeStream: [idle()],
5291
+ expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: true }]
5292
+ },
5246
5293
  {
5247
5294
  // Empty-final: a clean turn that ended on a tool call with no closing text.
5248
5295
  // The adapter emits NO final text — empty-final promotion is host/pump
@@ -6357,6 +6404,18 @@ var CODEX_CONFORMANCE_FIXTURES = [
6357
6404
  { type: "result", ok: true }
6358
6405
  ]
6359
6406
  },
6407
+ {
6408
+ // CT1144: the same zero-content success the claude-code suite pins, in
6409
+ // Codex's vocabulary — the thread starts, the turn completes, and nothing is
6410
+ // emitted in between. No Codex sample of the anomaly has been seen in the
6411
+ // wild; the fixture exists because the host's classification is runtime-
6412
+ // neutral, so a runtime that CAN produce this envelope must be shown
6413
+ // producing it in the shape the classifier reads.
6414
+ name: "zero-content success (empty runtime result)",
6415
+ request: makeRequest3(),
6416
+ nativeStream: [threadStarted(NEW_THREAD_ID), turnCompleted()],
6417
+ expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: true }]
6418
+ },
6360
6419
  {
6361
6420
  // Empty-final: a clean turn that ended on a tool call with no closing message.
6362
6421
  // The adapter emits NO final text — empty-final promotion is host/pump territory
@@ -8162,6 +8221,7 @@ ${reason}`,
8162
8221
  result: 0
8163
8222
  };
8164
8223
  let runtimeResultKind = null;
8224
+ let contentBearingEvents = 0;
8165
8225
  let latestSessionState = request.session;
8166
8226
  let settledDiagnostics = null;
8167
8227
  const committer = new TurnCommitter({
@@ -8260,6 +8320,7 @@ ${reason}`,
8260
8320
  for await (const event of adapter.runTurn(request, abortController.signal)) {
8261
8321
  transcript2?.write(event);
8262
8322
  eventCounts[event.type] += 1;
8323
+ if (isContentBearingEvent(event)) contentBearingEvents += 1;
8263
8324
  armIdle();
8264
8325
  if (abortController.signal.aborted) {
8265
8326
  turnLog.info("dispatcher: aborted mid-turn");
@@ -8320,6 +8381,11 @@ ${reason}`,
8320
8381
  okResult = false;
8321
8382
  resultReason = timeoutReason ?? "cancelled";
8322
8383
  }
8384
+ const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
8385
+ if (emptyResultReason) {
8386
+ okResult = false;
8387
+ resultReason = emptyResultReason;
8388
+ }
8323
8389
  if (!okResult && !resultReason) {
8324
8390
  resultReason = "no_result";
8325
8391
  }
@@ -8431,9 +8497,7 @@ ${reason}`,
8431
8497
  this.sessionWriteNotified.add(key);
8432
8498
  }
8433
8499
  const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
8434
- const noContentEvents = eventCounts.text === 0 && eventCounts.thinking === 0 && eventCounts.tool === 0;
8435
- const emptySuccessfulEnvelope = outcome === "success" && eventCounts.result > 0 && noContentEvents;
8436
- const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : emptySuccessfulEnvelope ? turnUsage?.inputTokens === 0 && turnUsage.outputTokens === 0 ? { kind: "empty_result" } : { kind: "empty_result_unverified" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8500
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8437
8501
  settledDiagnostics = {
8438
8502
  outcome,
8439
8503
  resultReason: diagnosticReason,
@@ -8449,10 +8513,14 @@ ${reason}`,
8449
8513
  )) {
8450
8514
  turnLog.warn(
8451
8515
  {
8516
+ workspaceId,
8452
8517
  turnId,
8453
8518
  conversationId: payload.conversationId,
8454
8519
  agentId: payload.agentId,
8455
8520
  runtime: turnRuntime,
8521
+ model: turnResolvedModel ?? null,
8522
+ usage: turnUsage ?? null,
8523
+ hadStoredSession: request.session != null,
8456
8524
  diagnostics: settledDiagnostics
8457
8525
  },
8458
8526
  "dispatcher: anomalous turn settled"
package/dist/runtime.js CHANGED
@@ -2684,6 +2684,28 @@ function normalizeTurnResultReason(reason) {
2684
2684
  }
2685
2685
  }
2686
2686
 
2687
+ // packages/agent-runtime/src/empty-result.ts
2688
+ function normalizeCommitText(text) {
2689
+ return text.trim();
2690
+ }
2691
+ function isContentBearingEvent(event) {
2692
+ switch (event.type) {
2693
+ case "text":
2694
+ return normalizeCommitText(event.body) !== "";
2695
+ case "thinking":
2696
+ return normalizeCommitText(event.text) !== "";
2697
+ case "tool":
2698
+ return true;
2699
+ case "session":
2700
+ case "result":
2701
+ return false;
2702
+ }
2703
+ }
2704
+ function classifyEmptyResult(input) {
2705
+ if (!input.ok || input.contentBearingEvents > 0) return null;
2706
+ return input.usage?.inputTokens === 0 && input.usage.outputTokens === 0 ? "empty_result" : "empty_result_unverified";
2707
+ }
2708
+
2687
2709
  // packages/agent-runtime/src/turn-request.ts
2688
2710
  import { z as z8 } from "zod";
2689
2711
  var contentBlockSchema = z8.discriminatedUnion("type", [
@@ -3005,7 +3027,7 @@ var TurnPump = class {
3005
3027
  this.sink = {
3006
3028
  onAssistantText: async (evt) => {
3007
3029
  if (opts.signal.aborted) return;
3008
- const body = evt.text.trim();
3030
+ const body = normalizeCommitText(evt.text);
3009
3031
  if (!body) return;
3010
3032
  const kind = evt.final ? "final" : "progress";
3011
3033
  const seq = opts.nextSeq();
@@ -3036,7 +3058,7 @@ var TurnPump = class {
3036
3058
  },
3037
3059
  onThinking: async (evt) => {
3038
3060
  if (opts.signal.aborted) return;
3039
- const trimmed = evt.text.trim();
3061
+ const trimmed = normalizeCommitText(evt.text);
3040
3062
  if (!trimmed) return;
3041
3063
  const text = truncate(trimmed, THINKING_TEXT_MAX_CHARS);
3042
3064
  const seq = opts.nextSeq();
@@ -3714,6 +3736,19 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
3714
3736
  { type: "result", ok: false, reason: "result_error:error_max_turns" }
3715
3737
  ]
3716
3738
  },
3739
+ {
3740
+ // CT1144: the empty-runtime anomaly's exact captured trace — `system/init`
3741
+ // then a successful `result`, with nothing in between. Distinct from
3742
+ // `empty-final` below: THAT turn did work (a tool ran) and just didn't speak;
3743
+ // this one produced nothing at all. The adapter's job is unchanged either way
3744
+ // — report faithfully what the runtime said — so it must still emit
3745
+ // `result{ok:true}` here; the host is what decides that a success with no
3746
+ // content-bearing event is a failed turn.
3747
+ name: "zero-content success (empty runtime result)",
3748
+ request: makeRequest(),
3749
+ nativeStream: [init("s1"), resultSuccess("s1")],
3750
+ expected: [sessionEvent("s1"), { type: "result", ok: true }]
3751
+ },
3717
3752
  {
3718
3753
  // Empty-final: a clean turn that ended on a tool call with no closing text.
3719
3754
  // The adapter emits NO final text — empty-final promotion is host/pump
@@ -4750,6 +4785,18 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
4750
4785
  ],
4751
4786
  expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "auth_expired" }]
4752
4787
  },
4788
+ {
4789
+ // CT1144: the same zero-content success the claude-code suite pins, in
4790
+ // opencode's vocabulary — the session goes idle having emitted no part at
4791
+ // all. No opencode sample of the anomaly has been seen in the wild; the
4792
+ // fixture exists because the host's classification is runtime-neutral, so a
4793
+ // runtime that CAN produce this envelope must be shown producing it in the
4794
+ // shape the classifier reads.
4795
+ name: "zero-content success (empty runtime result)",
4796
+ request: makeRequest2(),
4797
+ nativeStream: [idle()],
4798
+ expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: true }]
4799
+ },
4753
4800
  {
4754
4801
  // Empty-final: a clean turn that ended on a tool call with no closing text.
4755
4802
  // The adapter emits NO final text — empty-final promotion is host/pump
@@ -5864,6 +5911,18 @@ var CODEX_CONFORMANCE_FIXTURES = [
5864
5911
  { type: "result", ok: true }
5865
5912
  ]
5866
5913
  },
5914
+ {
5915
+ // CT1144: the same zero-content success the claude-code suite pins, in
5916
+ // Codex's vocabulary — the thread starts, the turn completes, and nothing is
5917
+ // emitted in between. No Codex sample of the anomaly has been seen in the
5918
+ // wild; the fixture exists because the host's classification is runtime-
5919
+ // neutral, so a runtime that CAN produce this envelope must be shown
5920
+ // producing it in the shape the classifier reads.
5921
+ name: "zero-content success (empty runtime result)",
5922
+ request: makeRequest3(),
5923
+ nativeStream: [threadStarted(NEW_THREAD_ID), turnCompleted()],
5924
+ expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: true }]
5925
+ },
5867
5926
  {
5868
5927
  // Empty-final: a clean turn that ended on a tool call with no closing message.
5869
5928
  // The adapter emits NO final text — empty-final promotion is host/pump territory
@@ -7669,6 +7728,7 @@ ${reason}`,
7669
7728
  result: 0
7670
7729
  };
7671
7730
  let runtimeResultKind = null;
7731
+ let contentBearingEvents = 0;
7672
7732
  let latestSessionState = request.session;
7673
7733
  let settledDiagnostics = null;
7674
7734
  const committer = new TurnCommitter({
@@ -7767,6 +7827,7 @@ ${reason}`,
7767
7827
  for await (const event of adapter.runTurn(request, abortController.signal)) {
7768
7828
  transcript?.write(event);
7769
7829
  eventCounts[event.type] += 1;
7830
+ if (isContentBearingEvent(event)) contentBearingEvents += 1;
7770
7831
  armIdle();
7771
7832
  if (abortController.signal.aborted) {
7772
7833
  turnLog.info("dispatcher: aborted mid-turn");
@@ -7827,6 +7888,11 @@ ${reason}`,
7827
7888
  okResult = false;
7828
7889
  resultReason = timeoutReason ?? "cancelled";
7829
7890
  }
7891
+ const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
7892
+ if (emptyResultReason) {
7893
+ okResult = false;
7894
+ resultReason = emptyResultReason;
7895
+ }
7830
7896
  if (!okResult && !resultReason) {
7831
7897
  resultReason = "no_result";
7832
7898
  }
@@ -7938,9 +8004,7 @@ ${reason}`,
7938
8004
  this.sessionWriteNotified.add(key);
7939
8005
  }
7940
8006
  const outcome = skipState.skipped ? "skipped" : userCancelled ? "cancelled" : okResult ? "success" : "failure";
7941
- const noContentEvents = eventCounts.text === 0 && eventCounts.thinking === 0 && eventCounts.tool === 0;
7942
- const emptySuccessfulEnvelope = outcome === "success" && eventCounts.result > 0 && noContentEvents;
7943
- const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : emptySuccessfulEnvelope ? turnUsage?.inputTokens === 0 && turnUsage.outputTokens === 0 ? { kind: "empty_result" } : { kind: "empty_result_unverified" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8007
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
7944
8008
  settledDiagnostics = {
7945
8009
  outcome,
7946
8010
  resultReason: diagnosticReason,
@@ -7956,10 +8020,14 @@ ${reason}`,
7956
8020
  )) {
7957
8021
  turnLog.warn(
7958
8022
  {
8023
+ workspaceId,
7959
8024
  turnId,
7960
8025
  conversationId: payload.conversationId,
7961
8026
  agentId: payload.agentId,
7962
8027
  runtime: turnRuntime,
8028
+ model: turnResolvedModel ?? null,
8029
+ usage: turnUsage ?? null,
8030
+ hadStoredSession: request.session != null,
7963
8031
  diagnostics: settledDiagnostics
7964
8032
  },
7965
8033
  "dispatcher: anomalous turn settled"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.37",
3
+ "version": "0.6.38",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",