@agent-native/core 0.94.2 → 0.95.0

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.
Files changed (85) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/pr-visual-recap.mdx +20 -12
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/a2a/artifact-response.ts +154 -1
  6. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +3 -0
  7. package/corpus/core/src/agent/engine/registry.ts +6 -1
  8. package/corpus/core/src/agent/engine/types.ts +2 -0
  9. package/corpus/core/src/cli/code-agent-executor.ts +61 -1
  10. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  11. package/corpus/core/src/cli/recap.ts +262 -25
  12. package/corpus/core/src/client/settings/UsageSection.tsx +26 -5
  13. package/corpus/core/src/index.ts +2 -0
  14. package/corpus/core/src/integrations/a2a-continuation-processor.ts +118 -31
  15. package/corpus/core/src/integrations/a2a-continuations-store.ts +165 -7
  16. package/corpus/core/src/integrations/adapters/slack.ts +77 -7
  17. package/corpus/core/src/integrations/types.ts +30 -0
  18. package/corpus/core/src/integrations/webhook-handler.ts +54 -18
  19. package/corpus/core/src/scripts/call-agent.ts +1 -0
  20. package/corpus/core/src/server/request-context.ts +2 -0
  21. package/corpus/core/src/usage/store.ts +82 -20
  22. package/corpus/templates/plan/actions/record-recap-usage-cost.ts +49 -0
  23. package/corpus/templates/plan/actions/record-recap-usage.ts +15 -22
  24. package/dist/a2a/artifact-response.d.ts.map +1 -1
  25. package/dist/a2a/artifact-response.js +127 -1
  26. package/dist/a2a/artifact-response.js.map +1 -1
  27. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  28. package/dist/agent/engine/ai-sdk-engine.js +3 -0
  29. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  30. package/dist/agent/engine/registry.d.ts +1 -1
  31. package/dist/agent/engine/registry.d.ts.map +1 -1
  32. package/dist/agent/engine/registry.js +2 -0
  33. package/dist/agent/engine/registry.js.map +1 -1
  34. package/dist/agent/engine/types.d.ts +2 -0
  35. package/dist/agent/engine/types.d.ts.map +1 -1
  36. package/dist/agent/engine/types.js.map +1 -1
  37. package/dist/cli/code-agent-executor.d.ts +2 -0
  38. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  39. package/dist/cli/code-agent-executor.js +52 -3
  40. package/dist/cli/code-agent-executor.js.map +1 -1
  41. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  42. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  43. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  44. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  45. package/dist/cli/recap.d.ts +22 -2
  46. package/dist/cli/recap.d.ts.map +1 -1
  47. package/dist/cli/recap.js +199 -22
  48. package/dist/cli/recap.js.map +1 -1
  49. package/dist/client/settings/UsageSection.d.ts.map +1 -1
  50. package/dist/client/settings/UsageSection.js +12 -3
  51. package/dist/client/settings/UsageSection.js.map +1 -1
  52. package/dist/collab/struct-routes.d.ts +1 -1
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
  57. package/dist/integrations/a2a-continuation-processor.js +76 -21
  58. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  59. package/dist/integrations/a2a-continuations-store.d.ts +4 -1
  60. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  61. package/dist/integrations/a2a-continuations-store.js +130 -6
  62. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  63. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  64. package/dist/integrations/adapters/slack.js +56 -7
  65. package/dist/integrations/adapters/slack.js.map +1 -1
  66. package/dist/integrations/types.d.ts +25 -0
  67. package/dist/integrations/types.d.ts.map +1 -1
  68. package/dist/integrations/types.js.map +1 -1
  69. package/dist/integrations/webhook-handler.js +54 -16
  70. package/dist/integrations/webhook-handler.js.map +1 -1
  71. package/dist/resources/handlers.d.ts +2 -2
  72. package/dist/scripts/call-agent.d.ts.map +1 -1
  73. package/dist/scripts/call-agent.js +1 -0
  74. package/dist/scripts/call-agent.js.map +1 -1
  75. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  76. package/dist/server/request-context.d.ts +2 -0
  77. package/dist/server/request-context.d.ts.map +1 -1
  78. package/dist/server/request-context.js.map +1 -1
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/dist/usage/store.d.ts +21 -0
  81. package/dist/usage/store.d.ts.map +1 -1
  82. package/dist/usage/store.js +54 -19
  83. package/dist/usage/store.js.map +1 -1
  84. package/docs/content/pr-visual-recap.mdx +20 -12
  85. package/package.json +1 -1
@@ -181,6 +181,12 @@ export interface PlatformAdapterCapabilities {
181
181
  }
182
182
 
183
183
  export interface PlatformRunProgress {
184
+ /**
185
+ * Opaque, provider-owned reference for resuming this progress surface from a
186
+ * durable continuation. It deliberately contains no user content,
187
+ * credentials, or provider payload.
188
+ */
189
+ ref?: PlatformRunProgressRef;
184
190
  /** Receive normalized agent events. Implementations should throttle writes. */
185
191
  onEvent(event: AgentChatEvent): Promise<void> | void;
186
192
  /** Finalize the provider-native progress surface with the answer. */
@@ -189,6 +195,19 @@ export interface PlatformRunProgress {
189
195
  fail?(message: string): Promise<void>;
190
196
  }
191
197
 
198
+ /**
199
+ * Safe, minimal reference to a provider-native run-progress surface.
200
+ *
201
+ * The field values are opaque to the framework. Adapters may use `kind` to
202
+ * distinguish their own resume strategy and `streamTs` to identify the
203
+ * provider-side stream. No incoming message text, platform payload, or
204
+ * credential belongs here.
205
+ */
206
+ export interface PlatformRunProgressRef {
207
+ kind: string;
208
+ streamTs: string;
209
+ }
210
+
192
211
  export interface ImmediateWebhookResponse {
193
212
  status: number;
194
213
  body: unknown;
@@ -304,6 +323,17 @@ export interface PlatformAdapter {
304
323
  incoming: IncomingMessage,
305
324
  ): Promise<PlatformRunProgress | null>;
306
325
 
326
+ /**
327
+ * Reattach a durable continuation to a provider-native progress surface
328
+ * previously started by this adapter. Adapters that cannot resume a native
329
+ * surface should omit this and the continuation will use its normal reply
330
+ * path instead.
331
+ */
332
+ resumeRunProgress?(
333
+ incoming: IncomingMessage,
334
+ ref: PlatformRunProgressRef,
335
+ ): Promise<PlatformRunProgress | null>;
336
+
307
337
  /**
308
338
  * Send a proactive outbound message to a platform destination. Adapters that
309
339
  * only support direct replies can omit this.
@@ -796,6 +796,7 @@ async function processIncomingMessage(
796
796
  attempts: opts.attempts,
797
797
  incoming,
798
798
  placeholderRef: opts.placeholderRef,
799
+ progressRef: progress?.ref,
799
800
  scopeId: incoming.integrationScopeId,
800
801
  principalType: opts.principalType ?? "user",
801
802
  lineage: {
@@ -872,8 +873,9 @@ async function processIncomingMessage(
872
873
  },
873
874
  async (completedRun: ActiveRun) => {
874
875
  let keepSlackInputWindow = false;
876
+ let queuedA2AContinuation = false;
875
877
  try {
876
- const queuedA2AContinuation = hasQueuedA2AContinuation(completedRun);
878
+ queuedA2AContinuation = hasQueuedA2AContinuation(completedRun);
877
879
  const slackInputRequest =
878
880
  incoming.platform === "slack"
879
881
  ? extractSlackInputRequest(completedRun)
@@ -963,7 +965,15 @@ async function processIncomingMessage(
963
965
  threadDeepLinkUrl,
964
966
  });
965
967
  let delivered = false;
966
- if (progress) {
968
+ if (queuedA2AContinuation && progress?.ref) {
969
+ // Post substantive parent results as a normal thread reply while
970
+ // the one continuation that claimed this resumable stream keeps
971
+ // it open for its eventual terminal result.
972
+ await adapter.sendResponse(outgoing, incoming, {
973
+ placeholderRef: opts.placeholderRef,
974
+ });
975
+ delivered = true;
976
+ } else if (progress) {
967
977
  try {
968
978
  await progress.complete(outgoing);
969
979
  delivered = true;
@@ -988,24 +998,35 @@ async function processIncomingMessage(
988
998
  keepSlackInputWindow = true;
989
999
  }
990
1000
  } else if (progress) {
991
- // The downstream agent owns the eventual reply, but this parent
992
- // integration run owns the native progress stream it opened. End
993
- // that stream now so Slack does not leave an eternal task card;
994
- // the continuation processor will post the final result into the
995
- // same thread when the downstream task completes.
996
- const deferred = adapter.formatAgentResponse(
997
- "The delegated agent is still working. I’ll post its final result in this thread automatically.",
998
- );
999
- try {
1000
- await progress.complete(deferred);
1001
- } catch {
1002
- // A failed complete must still terminate a provider-native
1003
- // stream when the adapter offers a failure lifecycle. Do not
1004
- // duplicate the deferred text as a regular reply: a later
1005
- // continuation delivery is authoritative.
1006
- await progress.fail?.(
1001
+ // A continuation owns the eventual final response. If the adapter
1002
+ // supplied a durable progress reference, leave the same native
1003
+ // stream open for the continuation processor to update and close;
1004
+ // ending it here discards the plan/task UI before the delegated
1005
+ // work has actually finished.
1006
+ if (progress.ref) {
1007
+ await progress.onEvent({
1008
+ type: "agent_call_progress",
1009
+ agent:
1010
+ getQueuedA2AContinuationAgent(completedRun) ??
1011
+ "delegated agent",
1012
+ state: "working",
1013
+ elapsedSeconds: 0,
1014
+ detail: "Continuing in the background",
1015
+ });
1016
+ } else {
1017
+ // Older adapters have no resumable native surface. Close their
1018
+ // stream cleanly; the continuation will deliver one standard
1019
+ // final reply when the downstream task is terminal.
1020
+ const deferred = adapter.formatAgentResponse(
1007
1021
  "The delegated agent is still working. I’ll post its final result in this thread automatically.",
1008
1022
  );
1023
+ try {
1024
+ await progress.complete(deferred);
1025
+ } catch {
1026
+ await progress.fail?.(
1027
+ "The delegated agent is still working. I’ll post its final result in this thread automatically.",
1028
+ );
1029
+ }
1009
1030
  }
1010
1031
  }
1011
1032
 
@@ -1036,6 +1057,10 @@ async function processIncomingMessage(
1036
1057
  `[integrations] Error sending response to ${incoming.platform}:`,
1037
1058
  err,
1038
1059
  );
1060
+ // A queued continuation owns the final platform response. Later
1061
+ // bookkeeping failures (for example, persisting this parent run)
1062
+ // must not close its resumable native stream with a false failure.
1063
+ if (queuedA2AContinuation) return;
1039
1064
  // Last-ditch: try to post a brief apology so the thread isn't silent.
1040
1065
  try {
1041
1066
  await progress?.fail?.(
@@ -1322,6 +1347,17 @@ function hasQueuedA2AContinuation(completedRun: ActiveRun): boolean {
1322
1347
  });
1323
1348
  }
1324
1349
 
1350
+ function getQueuedA2AContinuationAgent(completedRun: ActiveRun): string | null {
1351
+ for (let i = completedRun.events.length - 1; i >= 0; i--) {
1352
+ const event = completedRun.events[i]!.event;
1353
+ if (event.type !== "agent_call") continue;
1354
+ if (typeof event.agent === "string" && event.agent.trim()) {
1355
+ return event.agent;
1356
+ }
1357
+ }
1358
+ return null;
1359
+ }
1360
+
1325
1361
  function extractSlackInputRequest(
1326
1362
  completedRun: ActiveRun,
1327
1363
  ): { text: string } | null {
@@ -427,6 +427,7 @@ async function enqueueIntegrationContinuationIfPossible(
427
427
  externalThreadId: integration.incoming.externalThreadId,
428
428
  incoming: integration.incoming,
429
429
  placeholderRef: integration.placeholderRef,
430
+ progressRef: integration.progressRef,
430
431
  ownerEmail,
431
432
  orgId: getRequestOrgId() ?? null,
432
433
  agentName: agent.name,
@@ -164,6 +164,8 @@ export interface RequestContext {
164
164
  attempts?: number;
165
165
  incoming: import("../integrations/types.js").IncomingMessage;
166
166
  placeholderRef?: string;
167
+ /** Opaque provider-native progress surface for a durable continuation. */
168
+ progressRef?: import("../integrations/types.js").PlatformRunProgressRef;
167
169
  installationId?: string;
168
170
  scopeId?: string;
169
171
  principalType?: "user" | "service";
@@ -197,6 +197,8 @@ export interface UsageRecord {
197
197
  * provider-reported dollar cost so two surfaces agree exactly.
198
198
  */
199
199
  costCentsX100?: number;
200
+ /** Whether cost is provider-reported, estimated, or unavailable. */
201
+ costSource?: UsageCostSource;
200
202
  orgId?: string;
201
203
  runId?: string;
202
204
  threadId?: string;
@@ -206,6 +208,8 @@ export interface UsageRecord {
206
208
  sourceId?: string;
207
209
  }
208
210
 
211
+ export type UsageCostSource = "reported" | "estimated" | "unavailable";
212
+
209
213
  let _initPromise: Promise<void> | undefined;
210
214
 
211
215
  async function ensureUsageTable(): Promise<void> {
@@ -221,6 +225,7 @@ async function ensureUsageTable(): Promise<void> {
221
225
  cache_read_tokens ${intType()} NOT NULL DEFAULT 0,
222
226
  cache_write_tokens ${intType()} NOT NULL DEFAULT 0,
223
227
  cost_cents_x100 ${intType()} NOT NULL DEFAULT 0,
228
+ cost_source TEXT NOT NULL DEFAULT 'estimated',
224
229
  model TEXT NOT NULL DEFAULT '',
225
230
  label TEXT NOT NULL DEFAULT 'chat',
226
231
  app TEXT NOT NULL DEFAULT '',
@@ -240,6 +245,7 @@ async function ensureUsageTable(): Promise<void> {
240
245
  const additions: Array<[string, string]> = [
241
246
  ["cache_read_tokens", `${intType()} NOT NULL DEFAULT 0`],
242
247
  ["cache_write_tokens", `${intType()} NOT NULL DEFAULT 0`],
248
+ ["cost_source", `TEXT NOT NULL DEFAULT 'estimated'`],
243
249
  ["label", `TEXT NOT NULL DEFAULT 'chat'`],
244
250
  ["app", `TEXT NOT NULL DEFAULT ''`],
245
251
  ["ref_id", `TEXT NOT NULL DEFAULT ''`],
@@ -379,6 +385,7 @@ export async function recordUsage(
379
385
  app,
380
386
  refId,
381
387
  costCentsX100,
388
+ costSource,
382
389
  orgId,
383
390
  runId,
384
391
  threadId,
@@ -410,14 +417,24 @@ export async function recordUsage(
410
417
 
411
418
  // Prefer an explicit precomputed cost (e.g. a provider-reported dollar cost);
412
419
  // otherwise derive it from tokens via the pricing table.
420
+ const resolvedCostSource =
421
+ costSource ?? (costCentsX100 == null ? "estimated" : "reported");
413
422
  const costX100 =
414
- costCentsX100 ??
415
- calculateCost(inTok, outTok, modelName, cacheReadTokens, cacheWriteTokens);
423
+ resolvedCostSource === "unavailable"
424
+ ? 0
425
+ : (costCentsX100 ??
426
+ calculateCost(
427
+ inTok,
428
+ outTok,
429
+ modelName,
430
+ cacheReadTokens,
431
+ cacheWriteTokens,
432
+ ));
416
433
  const id = Date.now() * 1000 + Math.floor(Math.random() * 1000);
417
434
  await client.execute({
418
435
  sql: `INSERT INTO token_usage
419
- (id, owner_email, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_cents_x100, model, label, app, ref_id, org_id, run_id, thread_id, task_id, integration_scope_id, source_platform, source_id, created_at)
420
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
436
+ (id, owner_email, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_cents_x100, cost_source, model, label, app, ref_id, org_id, run_id, thread_id, task_id, integration_scope_id, source_platform, source_id, created_at)
437
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
421
438
  args: [
422
439
  id,
423
440
  ownerEmail,
@@ -426,6 +443,7 @@ export async function recordUsage(
426
443
  cacheReadTokens,
427
444
  cacheWriteTokens,
428
445
  costX100,
446
+ resolvedCostSource,
429
447
  modelName,
430
448
  resolvedLabel,
431
449
  resolvedApp,
@@ -469,6 +487,7 @@ export interface UsageBucket {
469
487
  cacheReadTokens: number;
470
488
  cacheWriteTokens: number;
471
489
  cents: number;
490
+ cost: UsageCostAggregate;
472
491
  calls: number;
473
492
  }
474
493
 
@@ -476,9 +495,15 @@ export interface DailyBucket {
476
495
  /** YYYY-MM-DD (UTC) */
477
496
  date: string;
478
497
  cents: number;
498
+ cost: UsageCostAggregate;
479
499
  calls: number;
480
500
  }
481
501
 
502
+ export type UsageCostAggregate =
503
+ | { status: "known"; knownCents: number; unavailableCalls: 0 }
504
+ | { status: "partial"; knownCents: number; unavailableCalls: number }
505
+ | { status: "unavailable"; knownCents: 0; unavailableCalls: number };
506
+
482
507
  export interface UsageRecentEntry {
483
508
  id: number;
484
509
  createdAt: number;
@@ -490,11 +515,14 @@ export interface UsageRecentEntry {
490
515
  cacheReadTokens: number;
491
516
  cacheWriteTokens: number;
492
517
  cents: number;
518
+ costSource: UsageCostSource;
493
519
  }
494
520
 
495
521
  export interface UsageSummary {
496
522
  billing?: UsageBillingMode;
523
+ /** Legacy known-cost subtotal. Use totalCost to preserve unavailable spend. */
497
524
  totalCents: number;
525
+ totalCost: UsageCostAggregate;
498
526
  totalCalls: number;
499
527
  totalInputTokens: number;
500
528
  totalOutputTokens: number;
@@ -523,7 +551,8 @@ export async function getUsageSummary(
523
551
 
524
552
  const totalRow = await client.execute({
525
553
  sql: `SELECT
526
- COALESCE(SUM(cost_cents_x100), 0) AS cents,
554
+ COALESCE(SUM(CASE WHEN cost_source = 'unavailable' THEN 0 ELSE cost_cents_x100 END), 0) AS known_cents,
555
+ COALESCE(SUM(CASE WHEN cost_source = 'unavailable' THEN 1 ELSE 0 END), 0) AS unavailable_calls,
527
556
  COUNT(*) AS calls,
528
557
  COALESCE(SUM(input_tokens), 0) AS in_tok,
529
558
  COALESCE(SUM(output_tokens), 0) AS out_tok,
@@ -536,7 +565,8 @@ export async function getUsageSummary(
536
565
 
537
566
  const bucketSql = (col: string) => ({
538
567
  sql: `SELECT ${col} AS k,
539
- COALESCE(SUM(cost_cents_x100), 0) AS cents,
568
+ COALESCE(SUM(CASE WHEN cost_source = 'unavailable' THEN 0 ELSE cost_cents_x100 END), 0) AS known_cents,
569
+ COALESCE(SUM(CASE WHEN cost_source = 'unavailable' THEN 1 ELSE 0 END), 0) AS unavailable_calls,
540
570
  COUNT(*) AS calls,
541
571
  COALESCE(SUM(input_tokens), 0) AS in_tok,
542
572
  COALESCE(SUM(output_tokens), 0) AS out_tok,
@@ -545,16 +575,19 @@ export async function getUsageSummary(
545
575
  FROM token_usage
546
576
  WHERE owner_email = ? AND created_at >= ?
547
577
  GROUP BY ${col}
548
- ORDER BY cents DESC`,
578
+ ORDER BY known_cents DESC`,
549
579
  args: [options.ownerEmail, sinceMs],
550
580
  });
551
581
 
552
582
  const mapBuckets = (rows: unknown[]): UsageBucket[] =>
553
583
  rows.map((r) => {
554
584
  const row = r as Record<string, number | string | null>;
585
+ const knownCents = Number(row.known_cents ?? 0) / 100;
586
+ const unavailableCalls = Number(row.unavailable_calls ?? 0);
555
587
  return {
556
588
  key: String(row.k ?? ""),
557
- cents: Number(row.cents ?? 0) / 100,
589
+ cents: knownCents,
590
+ cost: buildUsageCostAggregate(knownCents, unavailableCalls),
558
591
  calls: Number(row.calls ?? 0),
559
592
  inputTokens: Number(row.in_tok ?? 0),
560
593
  outputTokens: Number(row.out_tok ?? 0),
@@ -573,30 +606,42 @@ export async function getUsageSummary(
573
606
  // date functions (SQLite `strftime`, Postgres `to_char`). Cheap enough
574
607
  // for a 30-day window; if this grows, swap for a dialect-aware query.
575
608
  const dayRows = await client.execute({
576
- sql: `SELECT created_at, cost_cents_x100 FROM token_usage
609
+ sql: `SELECT created_at, cost_cents_x100, cost_source FROM token_usage
577
610
  WHERE owner_email = ? AND created_at >= ?`,
578
611
  args: [options.ownerEmail, sinceMs],
579
612
  });
580
- const dayMap = new Map<string, { cents: number; calls: number }>();
581
- for (const row of dayRows.rows as Array<Record<string, number>>) {
613
+ const dayMap = new Map<
614
+ string,
615
+ { knownCentsX100: number; unavailableCalls: number; calls: number }
616
+ >();
617
+ for (const row of dayRows.rows as Array<Record<string, number | string>>) {
582
618
  const date = new Date(Number(row.created_at)).toISOString().slice(0, 10);
583
- const prev = dayMap.get(date) ?? { cents: 0, calls: 0 };
584
- prev.cents += Number(row.cost_cents_x100 ?? 0);
619
+ const prev = dayMap.get(date) ?? {
620
+ knownCentsX100: 0,
621
+ unavailableCalls: 0,
622
+ calls: 0,
623
+ };
624
+ if (row.cost_source === "unavailable") prev.unavailableCalls += 1;
625
+ else prev.knownCentsX100 += Number(row.cost_cents_x100 ?? 0);
585
626
  prev.calls += 1;
586
627
  dayMap.set(date, prev);
587
628
  }
588
629
  const byDay: DailyBucket[] = [...dayMap.entries()]
589
- .map(([date, v]) => ({
590
- date,
591
- cents: v.cents / 100,
592
- calls: v.calls,
593
- }))
630
+ .map(([date, v]) => {
631
+ const knownCents = v.knownCentsX100 / 100;
632
+ return {
633
+ date,
634
+ cents: knownCents,
635
+ cost: buildUsageCostAggregate(knownCents, v.unavailableCalls),
636
+ calls: v.calls,
637
+ };
638
+ })
594
639
  .sort((a, b) => a.date.localeCompare(b.date));
595
640
 
596
641
  const recentRows = await client.execute({
597
642
  sql: `SELECT id, created_at, label, app, model,
598
643
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
599
- cost_cents_x100
644
+ cost_cents_x100, cost_source
600
645
  FROM token_usage
601
646
  WHERE owner_email = ?
602
647
  ORDER BY created_at DESC
@@ -616,11 +661,15 @@ export async function getUsageSummary(
616
661
  cacheReadTokens: Number(row.cache_read_tokens ?? 0),
617
662
  cacheWriteTokens: Number(row.cache_write_tokens ?? 0),
618
663
  cents: Number(row.cost_cents_x100 ?? 0) / 100,
664
+ costSource: String(row.cost_source ?? "estimated") as UsageCostSource,
619
665
  }));
620
666
 
667
+ const knownCents = Number(t.known_cents ?? 0) / 100;
668
+ const unavailableCalls = Number(t.unavailable_calls ?? 0);
621
669
  return {
622
670
  billing: USD_USAGE_BILLING,
623
- totalCents: Number(t.cents ?? 0) / 100,
671
+ totalCents: knownCents,
672
+ totalCost: buildUsageCostAggregate(knownCents, unavailableCalls),
624
673
  totalCalls: Number(t.calls ?? 0),
625
674
  totalInputTokens: Number(t.in_tok ?? 0),
626
675
  totalOutputTokens: Number(t.out_tok ?? 0),
@@ -634,3 +683,16 @@ export async function getUsageSummary(
634
683
  recent,
635
684
  };
636
685
  }
686
+
687
+ function buildUsageCostAggregate(
688
+ knownCents: number,
689
+ unavailableCalls: number,
690
+ ): UsageCostAggregate {
691
+ if (unavailableCalls === 0) {
692
+ return { status: "known", knownCents, unavailableCalls: 0 };
693
+ }
694
+ if (knownCents === 0) {
695
+ return { status: "unavailable", knownCents: 0, unavailableCalls };
696
+ }
697
+ return { status: "partial", knownCents, unavailableCalls };
698
+ }
@@ -0,0 +1,49 @@
1
+ export type RecapUsageCostSource = "reported" | "estimated" | "unavailable";
2
+
3
+ export interface RecapUsageCostInput {
4
+ agent?: "claude" | "codex" | "openai-compatible";
5
+ model: string;
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ cacheReadTokens: number;
9
+ cacheWriteTokens: number;
10
+ reportedCostUsd?: number;
11
+ }
12
+
13
+ type CostEstimator = (
14
+ inputTokens: number,
15
+ outputTokens: number,
16
+ model: string,
17
+ cacheReadTokens: number,
18
+ cacheWriteTokens: number,
19
+ ) => number;
20
+
21
+ export function resolveRecapUsageCost(
22
+ input: RecapUsageCostInput,
23
+ estimateCost?: CostEstimator,
24
+ ): { costCentsX100: number | null; costSource: RecapUsageCostSource } {
25
+ if (input.reportedCostUsd != null) {
26
+ return {
27
+ costCentsX100: Math.max(1, Math.round(input.reportedCostUsd * 10_000)),
28
+ costSource: "reported",
29
+ };
30
+ }
31
+ if (input.agent === "openai-compatible") {
32
+ return { costCentsX100: null, costSource: "unavailable" };
33
+ }
34
+ if (!estimateCost) {
35
+ throw new Error(
36
+ "A cost estimator is required for standard recap backends.",
37
+ );
38
+ }
39
+ return {
40
+ costCentsX100: estimateCost(
41
+ input.inputTokens,
42
+ input.outputTokens,
43
+ input.model,
44
+ input.cacheReadTokens,
45
+ input.cacheWriteTokens,
46
+ ),
47
+ costSource: "estimated",
48
+ };
49
+ }
@@ -5,11 +5,12 @@ import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
7
  import { assertPlanEditor, nowIso } from "../server/plans.js";
8
+ import { resolveRecapUsageCost } from "./record-recap-usage-cost.js";
8
9
 
9
10
  /**
10
- * Attach token usage + a derived cost estimate to an existing recap, keyed by
11
+ * Attach token usage and an honest cost state to an existing recap, keyed by
11
12
  * plan id. The PR Visual Recap workflow calls this AFTER the coding agent
12
- * (Claude Code / Codex) exits — the recap is created mid-run, before the run's
13
+ * (Claude Code / Codex / OpenAI-compatible Agent-Native Code) exits — the recap is created mid-run, before the run's
13
14
  * own token total exists, so usage can't ride along with create-visual-recap.
14
15
  *
15
16
  * Internal by design: no `publicAgent`, so it is NOT advertised as an MCP tool
@@ -24,11 +25,11 @@ import { assertPlanEditor, nowIso } from "../server/plans.js";
24
25
  */
25
26
  export default defineAction({
26
27
  description:
27
- "Record the LLM token usage and derived cost for a recap, keyed by plan id. Internal mechanic used by the PR Visual Recap workflow after the agent run finishes; not an agent-facing tool.",
28
+ "Record LLM token usage and reported or estimated cost when available for a recap, keyed by plan id. Internal mechanic used by the PR Visual Recap workflow after the agent run finishes; not an agent-facing tool.",
28
29
  agentTool: false,
29
30
  schema: z.object({
30
31
  planId: z.string().min(1),
31
- agent: z.enum(["claude", "codex"]).optional(),
32
+ agent: z.enum(["claude", "codex", "openai-compatible"]).optional(),
32
33
  model: z.string().min(1),
33
34
  // Token counts must already be normalized to the cache-exclusive shape
34
35
  // calculateCost expects (the recap CLI strips Codex's cached_input_tokens
@@ -37,7 +38,8 @@ export default defineAction({
37
38
  outputTokens: z.number().int().nonnegative(),
38
39
  cacheReadTokens: z.number().int().nonnegative().default(0),
39
40
  cacheWriteTokens: z.number().int().nonnegative().default(0),
40
- // Claude Code reports a real dollar cost (total_cost_usd); Codex does not.
41
+ // Claude Code reports a real dollar cost; Codex and compatible providers
42
+ // generally provide tokens only.
41
43
  reportedCostUsd: z.number().nonnegative().optional(),
42
44
  }),
43
45
  run: async (args) => {
@@ -54,18 +56,10 @@ export default defineAction({
54
56
  .limit(1);
55
57
  if (!row) throw new Error(`Plan ${args.planId} not found`);
56
58
 
57
- // Prefer the agent's reported dollar cost; otherwise estimate from tokens.
58
- const reported = args.reportedCostUsd != null;
59
- const costCentsX100 = reported
60
- ? Math.max(1, Math.round(args.reportedCostUsd! * 10_000)) // USD → centicents
61
- : calculateCost(
62
- args.inputTokens,
63
- args.outputTokens,
64
- args.model,
65
- args.cacheReadTokens,
66
- args.cacheWriteTokens,
67
- );
68
- const costSource = reported ? "reported" : "estimated";
59
+ const { costCentsX100, costSource } = resolveRecapUsageCost(
60
+ args,
61
+ calculateCost,
62
+ );
69
63
  const recordedAt = nowIso();
70
64
 
71
65
  await db
@@ -84,10 +78,8 @@ export default defineAction({
84
78
  })
85
79
  .where(eq(schema.plans.id, args.planId));
86
80
 
87
- // Mirror into the shared token_usage table so recap spend shows up in the
88
- // existing Usage admin panel + cross-app aggregation. refId = plan id makes
89
- // it idempotent on re-push; costCentsX100 carries the exact figure stored on
90
- // the plan row (reported when available) so the two surfaces agree.
81
+ // Mirror tokens into the shared usage table. Unknown compatible-provider
82
+ // prices remain explicitly unavailable instead of inheriting a generic rate.
91
83
  await recordUsage({
92
84
  ownerEmail: row.ownerEmail,
93
85
  inputTokens: args.inputTokens,
@@ -98,7 +90,8 @@ export default defineAction({
98
90
  label: "visual-recap",
99
91
  app: "plan",
100
92
  refId: args.planId,
101
- costCentsX100,
93
+ costCentsX100: costCentsX100 ?? undefined,
94
+ costSource,
102
95
  });
103
96
 
104
97
  return { planId: args.planId, costCentsX100, costSource };
@@ -1 +1 @@
1
- {"version":3,"file":"artifact-response.d.ts","sourceRoot":"","sources":["../../src/a2a/artifact-response.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0BAA0B,CAAC,EAAE,OAAO,CAAC;CACtC;AAo8BD,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,oBAAoB,EAAE,EACnC,OAAO,GAAE,0BAA+B,GACvC,MAAM,CAsIR;AAED,wBAAgB,kCAAkC,CAChD,WAAW,EAAE,oBAAoB,EAAE,EACnC,OAAO,GAAE,0BAA+B,GACvC,MAAM,GAAG,IAAI,CA8Bf"}
1
+ {"version":3,"file":"artifact-response.d.ts","sourceRoot":"","sources":["../../src/a2a/artifact-response.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0BAA0B,CAAC,EAAE,OAAO,CAAC;CACtC;AA6lCD,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,oBAAoB,EAAE,EACnC,OAAO,GAAE,0BAA+B,GACvC,MAAM,CAsIR;AAED,wBAAgB,kCAAkC,CAChD,WAAW,EAAE,oBAAoB,EAAE,EACnC,OAAO,GAAE,0BAA+B,GACvC,MAAM,GAAG,IAAI,CA8Bf"}