From c1ea5d8d456e505aca0cd0bd68afe0f192a16330 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 20 Sep 2026 14:30:29 -0400 Subject: [PATCH 1/2] fix(agent-core-v2): require ASCII tower mission titles and record token usage Non-ASCII mission titles slug to a generic branch name that collides across missions, so TowerPlan now rejects them with a rewrite hint. Tower messages, findings, and reviews record the sender's cumulative token count, and task cards lead with the mission id. --- .../tower-ascii-titles-and-token-usage.md | 5 + .../injection/tower-mode-full-reminder.md | 2 +- .../src/features/tower/protocol/paths.ts | 4 + .../src/features/tower/protocol/store.ts | 37 +++++- .../tower/tools/finding/findingTool.ts | 7 +- .../src/features/tower/tools/plan/plan.md | 2 +- .../src/features/tower/tools/plan/plan.ts | 6 +- .../features/tower/tools/review/reviewTool.ts | 7 +- .../src/features/tower/tools/send/sendTool.ts | 7 +- .../features/tower/tools/spawn/spawnTool.ts | 24 ++-- .../src/features/tower/tools/support.ts | 11 ++ .../test/features/tower/store.test.ts | 123 ++++++++++++++++++ .../features/tower/tools/spawnTool.test.ts | 34 ++++- .../features/tower/tools/towerTools.test.ts | 43 +++++- .../test/features/tower/towerService.test.ts | 2 + 15 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 .changeset/tower-ascii-titles-and-token-usage.md diff --git a/.changeset/tower-ascii-titles-and-token-usage.md b/.changeset/tower-ascii-titles-and-token-usage.md new file mode 100644 index 000000000..06221f414 --- /dev/null +++ b/.changeset/tower-ascii-titles-and-token-usage.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Tower mode: mission titles must be printable ASCII, tower messages, findings, and reviews record the sender's token usage, and task cards show the mission id. diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md index 1fb767bcf..041683be2 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md @@ -21,7 +21,7 @@ Working principles: ## Tower workflow 1. **Init** — `TowerInit`. It creates `.tower/` and records the base branch — when the human enabled tower mode with `/tower `, the workspace and base branch are already set up, so `TowerInit` just confirms them. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. When `TowerInit` reports carried-over open missions from a previous session, settle them **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — missions that are neither merged nor abandoned keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them. -2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. +2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Titles must be **printable ASCII English** — any non-ASCII character is rejected and forces a re-plan. Give every title a unique identifier word (a business code, a module name, a ticket id). Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. 3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead, always in the background (`run_in_background=true`). Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers use the configured secondary model when `[secondary_model]` provides one; otherwise they inherit your model. Reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. 4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act: - Review request → first reconcile the worker's report against the mission tasks **item by item** (a silently dropped task means the mission is not done — send it back), then `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch) — the briefing hands the reviewer the mission text and the worker's report, so the review verifies intent, not only code health. Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands. diff --git a/packages/agent-core-v2/src/features/tower/protocol/paths.ts b/packages/agent-core-v2/src/features/tower/protocol/paths.ts index 02ec6bf15..45b2d4243 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/paths.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/paths.ts @@ -40,6 +40,10 @@ export function slugify(text: string, maxLength = 60): string { return slug.length > 0 ? slug : 'item'; } +export function hasNonAsciiCharacters(text: string): boolean { + return /[^\u0020-\u007E]/.test(text); +} + export function targetSlug(target: string): string { const cleaned = target.trim().replace(/^#/, 'pr'); return slugify(cleaned.replaceAll(/[/#]+/g, '-')); diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index 09a4bf89d..d6fdefd84 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -41,6 +41,7 @@ import { isReservedTowerAgentName, dateDash, findingFileName, + hasNonAsciiCharacters, inboxFileName, missionFileName, reviewFileName, @@ -91,6 +92,7 @@ export interface TowerSendInput { readonly scope?: string; readonly action?: string; readonly consentRef?: string; + readonly tokens?: number; } export interface TowerFindingInput { @@ -101,6 +103,7 @@ export interface TowerFindingInput { readonly location?: string; readonly details: string; readonly suggestedFix: string; + readonly tokens?: number; } export interface TowerReviewInput { @@ -110,6 +113,7 @@ export interface TowerReviewInput { readonly findings: string; readonly checks?: readonly string[]; readonly decision: string; + readonly tokens?: number; } export interface TowerMissionPatch { @@ -497,6 +501,14 @@ export class TowerStore { if (input.length === 0) { throw new TowerProtocolError('TowerPlan needs at least one mission'); } + for (const item of input) { + if (hasNonAsciiCharacters(item.title)) { + const offending = /[^\u0020-\u007E]/.exec(item.title)![0]; + throw new TowerProtocolError( + `mission title "${item.title}" contains non-ASCII characters (first: "${offending}") — titles must be printable ASCII English: the title becomes the branch/worktree slug, and non-ASCII text slugs to a generic word like "item" that collides across missions; rewrite the title in English with a unique identifier word (e.g. a business code like B010100) and plan again`, + ); + } + } const state = await this.load(); const startIndex = state.missions.length; @@ -733,11 +745,17 @@ export class TowerStore { scope: input.scope, action: input.action, consent_ref: input.consentRef, + tokens: String(input.tokens ?? -1), }); const content = `${frontmatter}\n\n${input.body.trim()}\n`; const baseName = inboxFileName({ from: callerName, to, subject: input.subject }); const rel = await this.writeUnique(join(INBOX_DIR, baseName), content); - await this.appendLog(callerName, 'inbox.send', { to, subject: slugify(input.subject) }, rel); + await this.appendLog( + callerName, + 'inbox.send', + { to, subject: slugify(input.subject), tokens: input.tokens ?? -1 }, + rel, + ); return rel; } @@ -798,6 +816,7 @@ export class TowerStore { `**Type**: ${input.type}`, `**Severity**: ${input.severity ?? 'medium'}`, `**Mission**: ${mission === undefined ? '(none)' : `${mission.id} — ${mission.title}`}`, + `**Tokens**: ${String(input.tokens ?? -1)}`, '', '---', '', @@ -829,7 +848,12 @@ export class TowerStore { slug: input.title, }); const rel = await this.writeUnique(join(FINDINGS_DIR, baseName), lines.join('\n')); - await this.appendLog(callerName, 'finding.file', { type: input.type, slug: slugify(input.title) }, rel); + await this.appendLog( + callerName, + 'finding.file', + { type: input.type, slug: slugify(input.title), tokens: input.tokens ?? -1 }, + rel, + ); return rel; } @@ -875,6 +899,7 @@ export class TowerStore { merge: input.merge, reviewed_commit: reviewedCommit, mission: reviewMissionId, + tokens: String(input.tokens ?? -1), }); const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n'); const content = [ @@ -899,7 +924,13 @@ export class TowerStore { await this.appendLog( callerName, 'review.write', - { target: input.target, round, verdict: input.status, reviewed: reviewedCommit.slice(0, 7) }, + { + target: input.target, + round, + verdict: input.status, + reviewed: reviewedCommit.slice(0, 7), + tokens: input.tokens ?? -1, + }, rel, ); return rel; diff --git a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts index 2866bbe49..93d5d367c 100644 --- a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts @@ -1,9 +1,10 @@ -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, newTowerStore, runTowerTool } from '../support'; +import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './finding.md?raw'; import { ITowerFindingTool, @@ -20,6 +21,7 @@ export class TowerFindingTool implements ITowerFindingTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionUsageService private readonly usage: ISessionUsageService, ) {} resolveExecution(args: TowerFindingToolInput): ToolExecution { @@ -39,6 +41,7 @@ export class TowerFindingTool implements ITowerFindingTool { location: args.location, details: args.details, suggestedFix: args.suggested_fix, + tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), }); return { output: `finding filed: ${rel}\nThe tower will route it — do not fix out-of-scope issues yourself.`, diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md index 3301e1578..ed4fc56b4 100644 --- a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md @@ -2,4 +2,4 @@ Split the tower goal into missions. Each mission gets an id (M1, M2, …), a bra Write tasks as verifiable check items — the worker ticks them off, the completion report reconciles against them item by item, and the reviewer maps every one to the diff. When the user's own words carry intent your paraphrase could lose, copy the key sentences into `context` verbatim (when in doubt, include it): context supplements your paraphrase, never replaces it, travels with the mission into the worker and reviewer briefings, and is never the full conversation history. -Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), deps must reference existing mission ids, and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). +Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), deps must reference existing mission ids, and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Titles must be printable ASCII English: a title containing any non-ASCII character (CJK, Cyrillic, Hangul, accented letters) is rejected outright, because non-ASCII text slugs to a generic word like "item" that collides across missions, while printable ASCII punctuation (spaces, "-", "_", "+") folds into the slug without losing words — write the title in English and include a unique identifier word (e.g. a business code like B010100). Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts b/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts index c9fe10193..f55493700 100644 --- a/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts @@ -9,7 +9,11 @@ export const TowerPlanToolInputSchema = z .array( z .object({ - title: z.string().describe('Short mission title; becomes the branch/worktree slug'), + title: z + .string() + .describe( + 'Short mission title; becomes the branch/worktree slug. Printable ASCII English only — any non-ASCII character is rejected. Include a unique identifier word (a business code, a module name, a ticket id).', + ), scope: z .array(z.string()) .min(1) diff --git a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts index fc94b42b8..32d5ee5ce 100644 --- a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts @@ -1,9 +1,10 @@ -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, newTowerStore, runTowerTool } from '../support'; +import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './review.md?raw'; import { ITowerReviewTool, @@ -20,6 +21,7 @@ export class TowerReviewTool implements ITowerReviewTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionUsageService private readonly usage: ISessionUsageService, ) {} resolveExecution(args: TowerReviewToolInput): ToolExecution { @@ -38,6 +40,7 @@ export class TowerReviewTool implements ITowerReviewTool { findings: args.findings, checks: args.checks, decision: args.decision, + tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), }); return { output: `review submitted: ${rel}\nAlso notify the branch author (or the tower) with TowerSend so the verdict is seen.`, diff --git a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts index e06dcbd78..2bcabc419 100644 --- a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts @@ -1,13 +1,14 @@ -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionEventBus } from '#/app/event/eventBus'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index'; import { TowerInboxSent } from '#/features/tower/towerOps'; -import { callerName, newTowerStore, runTowerTool } from '../support'; +import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './send.md?raw'; import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send'; @@ -22,6 +23,7 @@ export class TowerSendTool implements ITowerSendTool { @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ISessionEventBus private readonly sessionBus: ISessionEventBus, @IAgentTaskService private readonly tasks: IAgentTaskService, + @ISessionUsageService private readonly usage: ISessionUsageService, ) {} resolveExecution(args: TowerSendToolInput): ToolExecution { @@ -41,6 +43,7 @@ export class TowerSendTool implements ITowerSendTool { scope: args.scope, action: args.action, consentRef: args.consent_ref, + tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), }); if ( this.sessionBus !== undefined && diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 418acc3b4..45490a1dd 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -171,11 +171,22 @@ export class TowerSpawnTool implements ITowerSpawnTool { } } - const prompt = await this.buildPrompt(args, store, state, mission, reviewTarget); + const reviewMission = + reviewTarget !== undefined ? resolveMissionByBranch(state, reviewTarget) : undefined; + const prompt = await this.buildPrompt( + args, + store, + state, + mission, + reviewTarget, + reviewMission, + ); const description = mission !== undefined - ? `tower worker ${args.name}: ${mission.title}` - : `tower reviewer ${args.name}: ${reviewTarget ?? ''}`; + ? `${mission.id} ${args.name}: ${mission.title}` + : reviewMission !== undefined + ? `${reviewMission.id} review: ${reviewTarget ?? ''}` + : `review ${args.name}: ${reviewTarget ?? ''}`; const gate = this.rateLimit.acquire(); if (!gate.ok) { @@ -234,10 +245,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { kind: args.kind, missionId: mission?.id, reviewTarget, - reviewMissionId: - reviewTarget !== undefined - ? resolveMissionByBranch(state, reviewTarget)?.id - : undefined, + reviewMissionId: reviewMission?.id, worktree: mission?.worktree, branch: mission?.branch, spawnedAt: new Date().toISOString(), @@ -374,6 +382,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { state: TowerState, mission: TowerMission | undefined, reviewTarget: string | undefined, + targetMission: TowerMission | undefined, ): Promise { const extra = args.instructions !== undefined && args.instructions.trim().length > 0 @@ -434,7 +443,6 @@ export class TowerSpawnTool implements ITowerSpawnTool { ); } const target = reviewTarget ?? ''; - const targetMission = resolveMissionByBranch(state, target); const author = targetMission?.owner; const reviewBase = targetMission !== undefined ? await store.diffBase(state, targetMission) : state.base; diff --git a/packages/agent-core-v2/src/features/tower/tools/support.ts b/packages/agent-core-v2/src/features/tower/tools/support.ts index 5f415d887..cd365c184 100644 --- a/packages/agent-core-v2/src/features/tower/tools/support.ts +++ b/packages/agent-core-v2/src/features/tower/tools/support.ts @@ -5,8 +5,11 @@ import { resolveTowerRepoRoot, type TowerState, } from '#/features/tower/protocol/index'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import type { ExecutableToolResult } from '#/tool/toolContract'; +import { grandTotal } from '#human/llm/usage'; export function newTowerStore(sessionContext: ISessionContext): TowerStore { return new TowerStore(resolveTowerRepoRoot(sessionContext.cwd)); @@ -23,6 +26,14 @@ export function callerName(agentId: string, store: TowerStore, state: TowerState return store.resolveCallerName(state, agentId); } +export function callerTokens( + usage: ISessionUsageService | undefined, + agent: AgentContext, +): number { + const total = usage?.status(agent).total; + return total === undefined ? -1 : grandTotal(total); +} + export async function runTowerTool( execute: () => Promise, ): Promise { diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts index f1e926625..65ec961a5 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -594,6 +594,42 @@ describe('plan', () => { ); expect((await store.load()).missions).toHaveLength(0); }); + + it('rejects titles containing non-ASCII characters and names the first offender', async () => { + await expect( + store.plan([{ title: '\u822A\u8FD0\u5E02\u573AB010100\u8FC1\u79FB', scope: ['src/x/**'] }]), + ).rejects.toThrow(/contains non-ASCII characters \(first: "\u822A"\)/); + expect((await store.load()).missions).toHaveLength(0); + }); + + it('rejects a batch when any title contains non-ASCII characters, even mixed with ASCII ones', async () => { + await expect( + store.plan([ + { title: 'Build engine', scope: ['src/engine/**'] }, + { title: '\u91D1\u878D\u5E02\u573AB010400\u8FC1\u79FB', scope: ['src/finance/**'] }, + ]), + ).rejects.toThrow(/contains non-ASCII characters/); + expect((await store.load()).missions).toHaveLength(0); + }); + + it('rejects Russian and Korean titles — they slug to the same generic word as CJK', async () => { + await expect( + store.plan([{ title: '\u0418\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043E\u0448\u0438\u0431\u043A\u0443 \u0432\u0445\u043E\u0434\u0430', scope: ['src/x/**'] }]), + ).rejects.toThrow(/contains non-ASCII characters \(first: "\u0418"\)/); + await expect( + store.plan([{ title: '\uD55C\uAE00 \uC81C\uBAA9', scope: ['src/x/**'] }]), + ).rejects.toThrow(/contains non-ASCII characters \(first: "\uD55C"\)/); + expect((await store.load()).missions).toHaveLength(0); + }); + + it('accepts titles with printable ASCII punctuation — dashes, underscores, spaces, plus signs', async () => { + const missions = await store.plan([ + { title: 'fix login_error + retry-logic', scope: ['src/x/**'] }, + ]); + + expect(missions[0]?.title).toBe('fix login_error + retry-logic'); + expect(missions[0]?.branch).toBe('feat/fix-login-error-retry-logic'); + }); }); describe('inbox send', () => { @@ -642,6 +678,33 @@ describe('inbox send', () => { expect(ref).toBe(rel); expect((await stat(join(repo, ref!))).isFile()).toBe(true); }); + + it('stamps the sender token count into the frontmatter and the activity log', async () => { + const rel = await store.send('tower', { + to: 'w1', + subject: 'get started', + body: 'please start on M1', + tokens: 12345, + }); + + const { fields } = parseFrontmatter(await readFile(join(repo, rel), 'utf8')); + expect(fields['tokens']).toBe('12345'); + + const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); + const sendLine = log.split('\n').find((line) => line.includes('inbox.send')); + expect(sendLine).toContain('tokens=12345'); + }); + + it('records tokens as -1 when the sender usage is unavailable', async () => { + const rel = await store.send('tower', { + to: 'w1', + subject: 'get started', + body: 'please start on M1', + }); + + const { fields } = parseFrontmatter(await readFile(join(repo, rel), 'utf8')); + expect(fields['tokens']).toBe('-1'); + }); }); describe('readInbox', () => { @@ -708,6 +771,29 @@ describe('findings', () => { expect(text).toContain('**Type**: bug'); expect(text).toContain('**Severity**: high'); }); + + it('stamps the reporter token count into the finding and defaults to -1', async () => { + const rel = await store.fileFinding('w1', { + type: 'bug', + title: 'leaky cache', + summary: 'the cache never invalidates', + details: 'no eviction path exists', + suggestedFix: 'add a ttl', + tokens: 4321, + }); + const withTokens = await readFile(join(repo, rel), 'utf8'); + expect(withTokens).toContain('**Tokens**: 4321'); + + const relDefault = await store.fileFinding('w1', { + type: 'improve', + title: 'second finding', + summary: 's', + details: 'd', + suggestedFix: 'f', + }); + const withoutTokens = await readFile(join(repo, relDefault), 'utf8'); + expect(withoutTokens).toContain('**Tokens**: -1'); + }); }); describe('merge gate', () => { @@ -835,6 +921,43 @@ describe('merge gate', () => { ); }); + it('stamps the reviewer token count into the review frontmatter and defaults to -1', async () => { + const mission = await setupMission({ + title: 'feature x', + scope: 'src/x/**', + file: 'src/x/x.ts', + content: 'x\n', + }); + await store.registerAgent( + rosterEntry({ name: 'rev', kind: 'reviewer', reviewTarget: mission.branch }), + ); + + await store.submitReview('rev', { + target: mission.branch, + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'ok', + tokens: 777, + }); + const withTokens = await store.latestReview(mission.branch); + expect( + parseFrontmatter(await readFile(join(repo, withTokens!.file), 'utf8')).fields['tokens'], + ).toBe('777'); + + await store.submitReview('rev', { + target: mission.branch, + status: 'clean', + merge: 'merge', + findings: 'none', + decision: 'ok again', + }); + const withoutTokens = await store.latestReview(mission.branch); + expect( + parseFrontmatter(await readFile(join(repo, withoutTokens!.file), 'utf8')).fields['tokens'], + ).toBe('-1'); + }); + it('refuses to merge while dependency missions are unmerged', async () => { const base = await setupMission({ title: 'base lib', diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index ad6becd99..c375a492d 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -240,7 +240,7 @@ describe('TowerSpawnTool', () => { taskInfoLookup = () => ({ taskId: 'task-1', kind: 'agent', - description: 'tower worker agent-build: Build gemm', + description: 'M1 agent-build: Build gemm', status: 'failed', stopReason: 'provider blew up', startedAt: 1, @@ -345,6 +345,14 @@ describe('TowerSpawnTool', () => { }); }); + it('describes the worker task with the mission id, name, and title', async () => { + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBeUndefined(); + const task = registerTask.mock.calls[0]?.[0] as SubagentTask; + expect(task.description).toBe('M1 agent-build: Build gemm'); + }); + it('honors the configured [subagent].timeout_ms for the registered task', async () => { subagentTimeoutMs = 30 * 60 * 1000; @@ -534,6 +542,30 @@ describe('TowerSpawnTool', () => { expect(entry?.worktree).toBeUndefined(); }); + it('describes the reviewer task with the review mission id when the branch resolves to a mission', async () => { + const result = await execute({ + name: 'reviewer-a', + kind: 'reviewer', + review_target: 'feat/build-gemm', + }); + + expect(result.isError).toBeUndefined(); + const task = registerTask.mock.calls[0]?.[0] as SubagentTask; + expect(task.description).toBe('M1 review: feat/build-gemm'); + }); + + it('describes the reviewer task with the reviewer name when the branch owns no mission', async () => { + const result = await execute({ + name: 'reviewer-b', + kind: 'reviewer', + review_target: 'feat/orphan-branch', + }); + + expect(result.isError).toBeUndefined(); + const task = registerTask.mock.calls[0]?.[0] as SubagentTask; + expect(task.description).toBe('review reviewer-b: feat/orphan-branch'); + }); + it('refuses a duplicate name and points at a background resume', async () => { await store.registerAgent({ name: 'agent-build', diff --git a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts index 4627e1e91..cc9ed3d66 100644 --- a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, stat, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, stat, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -16,9 +16,11 @@ import { ISessionManager } from '#/app/sessionManager/sessionManager'; import { TOWER_TOOL_CONTRIBUTIONS } from '#/features/tower/towerFeature'; import { IAgentTowerService } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; -import { TowerStore } from '#/features/tower/protocol/index'; +import { TowerStore, parseFrontmatter } from '#/features/tower/protocol/index'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import type { ExecutableTool } from '#/tool/toolContract'; +import type { TokenUsage } from '#human/llm/usage'; import { ITowerInitTool } from '#/features/tower/tools/init/init'; import { TowerInitTool } from '#/features/tower/tools/init/initTool'; @@ -76,6 +78,7 @@ let currentAgentId: string; let currentSessionId: string; let liveSessionIds: string[]; let liveAgentTaskIds: string[]; +let usageTotal: TokenUsage | undefined; const agentContexts = new Map(); beforeEach(async () => { @@ -90,6 +93,7 @@ beforeEach(async () => { currentAgentId = 'main'; liveSessionIds = []; liveAgentTaskIds = []; + usageTotal = undefined; currentSessionId = 'session-test'; agentContexts.clear(); @@ -146,6 +150,9 @@ beforeEach(async () => { reg.definePartialInstance(ITowerRateLimitService, { snapshot: () => ({ budget: 2, inflight: 0, blockedUntil: null }), }); + reg.definePartialInstance(ISessionUsageService, { + status: () => ({ total: usageTotal }), + }); reg.definePartialInstance(IAgentTaskService, { list: () => liveAgentTaskIds.map( @@ -406,6 +413,18 @@ describe('TowerPlanTool', () => { expect(result.output).toContain('already used by M1 (abandoned)'); expect((await new TowerStore(repo).load()).missions).toHaveLength(1); }); + + it('rejects a non-ASCII mission title and tells the tower to re-plan in English', async () => { + await initViaTool(); + + const result = await run(ix.get(ITowerPlanTool), { + missions: [{ title: '\u0418\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043E\u0448\u0438\u0431\u043A\u0443 \u0432\u0445\u043E\u0434\u0430', scope: ['src/x/**'] }], + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('contains non-ASCII characters'); + expect((await new TowerStore(repo).load()).missions).toHaveLength(0); + }); }); describe('TowerTeardownTool', () => { @@ -545,6 +564,26 @@ describe('TowerSendTool + TowerInboxTool', () => { expect(busy.output).not.toContain('has no running task'); }); + it('stamps the sender token count from the usage service into the message frontmatter', async () => { + usageTotal = { inputOther: 100, output: 50, inputCacheRead: 10, inputCacheCreation: 5 }; + + await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'metered', body: 'x' }); + + const dir = join(repo, '.tower/comms/inbox'); + const file = (await readdir(dir)).find((name) => name.includes('metered')); + const { fields } = parseFrontmatter(await readFile(join(dir, file!), 'utf8')); + expect(fields['tokens']).toBe('165'); + }); + + it('records tokens as -1 when the usage service reports nothing', async () => { + await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'unmetered', body: 'x' }); + + const dir = join(repo, '.tower/comms/inbox'); + const file = (await readdir(dir)).find((name) => name.includes('unmetered')); + const { fields } = parseFrontmatter(await readFile(join(dir, file!), 'utf8')); + expect(fields['tokens']).toBe('-1'); + }); + it('skips the delivery note for broadcasts and for sends from workers', async () => { const broadcast = await run(ix.get(ITowerSendTool), { to: 'all', subject: 'b', body: 'x' }); expect(broadcast.output).not.toContain('has no running task'); diff --git a/packages/agent-core-v2/test/features/tower/towerService.test.ts b/packages/agent-core-v2/test/features/tower/towerService.test.ts index 741749993..52f5252d2 100644 --- a/packages/agent-core-v2/test/features/tower/towerService.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerService.test.ts @@ -40,6 +40,7 @@ import { towerKey, TowerInboxSent } from '#/features/tower/towerOps'; import { TaskTerminatedNotice } from '#/agent/task/taskOps'; import { IAgentTaskService } from '#/agent/task/task'; import { SubagentStarted } from '#/session/subagent/mirrorAgentRun'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -2478,6 +2479,7 @@ describe('AgentTowerService', () => { makeAgentScopeContext({ agentId, agentScope: testWireScope('wire', 'tower-test'), generation: 0 }), bus, { list: () => [] } as unknown as IAgentTaskService, + undefined as unknown as ISessionUsageService, ); const result = await executeTool(tool, { turnId: 0, toolCallId: 'call_send', args: input, signal }); expect(result.isError).toBeFalsy(); From e4f58f73b622e811d70bd6f3457219ae170875f2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 20 Sep 2026 15:38:26 -0400 Subject: [PATCH 2/2] fix(agent-core-v2): rename tower tokens fields to token_count Align new tower count fields with the unit-suffix convention and drop unnecessary SubagentTask casts in spawn tool tests. --- .../src/features/tower/protocol/store.ts | 18 +++++++------- .../tower/tools/finding/findingTool.ts | 4 ++-- .../features/tower/tools/review/reviewTool.ts | 4 ++-- .../src/features/tower/tools/send/sendTool.ts | 4 ++-- .../src/features/tower/tools/support.ts | 2 +- .../test/features/tower/store.test.ts | 24 +++++++++---------- .../features/tower/tools/spawnTool.test.ts | 24 ++++++++++--------- .../features/tower/tools/towerTools.test.ts | 8 +++---- 8 files changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index d6fdefd84..80950ed37 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -92,7 +92,7 @@ export interface TowerSendInput { readonly scope?: string; readonly action?: string; readonly consentRef?: string; - readonly tokens?: number; + readonly token_count?: number; } export interface TowerFindingInput { @@ -103,7 +103,7 @@ export interface TowerFindingInput { readonly location?: string; readonly details: string; readonly suggestedFix: string; - readonly tokens?: number; + readonly token_count?: number; } export interface TowerReviewInput { @@ -113,7 +113,7 @@ export interface TowerReviewInput { readonly findings: string; readonly checks?: readonly string[]; readonly decision: string; - readonly tokens?: number; + readonly token_count?: number; } export interface TowerMissionPatch { @@ -745,7 +745,7 @@ export class TowerStore { scope: input.scope, action: input.action, consent_ref: input.consentRef, - tokens: String(input.tokens ?? -1), + token_count: String(input.token_count ?? -1), }); const content = `${frontmatter}\n\n${input.body.trim()}\n`; const baseName = inboxFileName({ from: callerName, to, subject: input.subject }); @@ -753,7 +753,7 @@ export class TowerStore { await this.appendLog( callerName, 'inbox.send', - { to, subject: slugify(input.subject), tokens: input.tokens ?? -1 }, + { to, subject: slugify(input.subject), token_count: input.token_count ?? -1 }, rel, ); return rel; @@ -816,7 +816,7 @@ export class TowerStore { `**Type**: ${input.type}`, `**Severity**: ${input.severity ?? 'medium'}`, `**Mission**: ${mission === undefined ? '(none)' : `${mission.id} — ${mission.title}`}`, - `**Tokens**: ${String(input.tokens ?? -1)}`, + `**Token count**: ${String(input.token_count ?? -1)}`, '', '---', '', @@ -851,7 +851,7 @@ export class TowerStore { await this.appendLog( callerName, 'finding.file', - { type: input.type, slug: slugify(input.title), tokens: input.tokens ?? -1 }, + { type: input.type, slug: slugify(input.title), token_count: input.token_count ?? -1 }, rel, ); return rel; @@ -899,7 +899,7 @@ export class TowerStore { merge: input.merge, reviewed_commit: reviewedCommit, mission: reviewMissionId, - tokens: String(input.tokens ?? -1), + token_count: String(input.token_count ?? -1), }); const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n'); const content = [ @@ -929,7 +929,7 @@ export class TowerStore { round, verdict: input.status, reviewed: reviewedCommit.slice(0, 7), - tokens: input.tokens ?? -1, + token_count: input.token_count ?? -1, }, rel, ); diff --git a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts index 93d5d367c..3cebc44f2 100644 --- a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts @@ -4,7 +4,7 @@ import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; +import { callerName, callerTokenCount, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './finding.md?raw'; import { ITowerFindingTool, @@ -41,7 +41,7 @@ export class TowerFindingTool implements ITowerFindingTool { location: args.location, details: args.details, suggestedFix: args.suggested_fix, - tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), + token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)), }); return { output: `finding filed: ${rel}\nThe tower will route it — do not fix out-of-scope issues yourself.`, diff --git a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts index 32d5ee5ce..764434bc9 100644 --- a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts @@ -4,7 +4,7 @@ import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; -import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; +import { callerName, callerTokenCount, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './review.md?raw'; import { ITowerReviewTool, @@ -40,7 +40,7 @@ export class TowerReviewTool implements ITowerReviewTool { findings: args.findings, checks: args.checks, decision: args.decision, - tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), + token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)), }); return { output: `review submitted: ${rel}\nAlso notify the branch author (or the tower) with TowerSend so the verdict is seen.`, diff --git a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts index 2bcabc419..cb3bad6cd 100644 --- a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts @@ -8,7 +8,7 @@ import type { ToolExecution } from '#/tool/toolContract'; import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index'; import { TowerInboxSent } from '#/features/tower/towerOps'; -import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support'; +import { callerName, callerTokenCount, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './send.md?raw'; import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send'; @@ -43,7 +43,7 @@ export class TowerSendTool implements ITowerSendTool { scope: args.scope, action: args.action, consentRef: args.consent_ref, - tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)), + token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)), }); if ( this.sessionBus !== undefined && diff --git a/packages/agent-core-v2/src/features/tower/tools/support.ts b/packages/agent-core-v2/src/features/tower/tools/support.ts index cd365c184..490906069 100644 --- a/packages/agent-core-v2/src/features/tower/tools/support.ts +++ b/packages/agent-core-v2/src/features/tower/tools/support.ts @@ -26,7 +26,7 @@ export function callerName(agentId: string, store: TowerStore, state: TowerState return store.resolveCallerName(state, agentId); } -export function callerTokens( +export function callerTokenCount( usage: ISessionUsageService | undefined, agent: AgentContext, ): number { diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts index 65ec961a5..35ed2cece 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -679,23 +679,23 @@ describe('inbox send', () => { expect((await stat(join(repo, ref!))).isFile()).toBe(true); }); - it('stamps the sender token count into the frontmatter and the activity log', async () => { + it('stamps the sender token_count into the frontmatter and the activity log', async () => { const rel = await store.send('tower', { to: 'w1', subject: 'get started', body: 'please start on M1', - tokens: 12345, + token_count: 12345, }); const { fields } = parseFrontmatter(await readFile(join(repo, rel), 'utf8')); - expect(fields['tokens']).toBe('12345'); + expect(fields['token_count']).toBe('12345'); const log = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); const sendLine = log.split('\n').find((line) => line.includes('inbox.send')); - expect(sendLine).toContain('tokens=12345'); + expect(sendLine).toContain('token_count=12345'); }); - it('records tokens as -1 when the sender usage is unavailable', async () => { + it('records token_count as -1 when the sender usage is unavailable', async () => { const rel = await store.send('tower', { to: 'w1', subject: 'get started', @@ -703,7 +703,7 @@ describe('inbox send', () => { }); const { fields } = parseFrontmatter(await readFile(join(repo, rel), 'utf8')); - expect(fields['tokens']).toBe('-1'); + expect(fields['token_count']).toBe('-1'); }); }); @@ -779,10 +779,10 @@ describe('findings', () => { summary: 'the cache never invalidates', details: 'no eviction path exists', suggestedFix: 'add a ttl', - tokens: 4321, + token_count: 4321, }); const withTokens = await readFile(join(repo, rel), 'utf8'); - expect(withTokens).toContain('**Tokens**: 4321'); + expect(withTokens).toContain('**Token count**: 4321'); const relDefault = await store.fileFinding('w1', { type: 'improve', @@ -792,7 +792,7 @@ describe('findings', () => { suggestedFix: 'f', }); const withoutTokens = await readFile(join(repo, relDefault), 'utf8'); - expect(withoutTokens).toContain('**Tokens**: -1'); + expect(withoutTokens).toContain('**Token count**: -1'); }); }); @@ -938,11 +938,11 @@ describe('merge gate', () => { merge: 'merge', findings: 'none', decision: 'ok', - tokens: 777, + token_count: 777, }); const withTokens = await store.latestReview(mission.branch); expect( - parseFrontmatter(await readFile(join(repo, withTokens!.file), 'utf8')).fields['tokens'], + parseFrontmatter(await readFile(join(repo, withTokens!.file), 'utf8')).fields['token_count'], ).toBe('777'); await store.submitReview('rev', { @@ -954,7 +954,7 @@ describe('merge gate', () => { }); const withoutTokens = await store.latestReview(mission.branch); expect( - parseFrontmatter(await readFile(join(repo, withoutTokens!.file), 'utf8')).fields['tokens'], + parseFrontmatter(await readFile(join(repo, withoutTokens!.file), 'utf8')).fields['token_count'], ).toBe('-1'); }); diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index c375a492d..ebfdd7b05 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -349,8 +349,8 @@ describe('TowerSpawnTool', () => { const result = await execute(WORKER_ARGS); expect(result.isError).toBeUndefined(); - const task = registerTask.mock.calls[0]?.[0] as SubagentTask; - expect(task.description).toBe('M1 agent-build: Build gemm'); + const task = registerTask.mock.calls[0]?.[0]; + expect(task?.description).toBe('M1 agent-build: Build gemm'); }); it('honors the configured [subagent].timeout_ms for the registered task', async () => { @@ -390,10 +390,11 @@ describe('TowerSpawnTool', () => { const result = await execute(WORKER_ARGS); expect(result.isError).toBeUndefined(); - const task = registerTask.mock.calls[0]?.[0] as SubagentTask; - const info = task.toInfo({ + const task = registerTask.mock.calls[0]?.[0]; + expect(task).toBeDefined(); + const info = task!.toInfo({ taskId: 'task-1', - description: task.description, + description: task!.description, status: 'running', startedAt: 1, endedAt: null, @@ -413,8 +414,9 @@ describe('TowerSpawnTool', () => { const result = await execute(WORKER_ARGS); expect(result.isError).toBeUndefined(); - const task = registerTask.mock.calls[0]?.[0] as SubagentTask; - expect(task.model).toBe('cheap/fast'); + const task = registerTask.mock.calls[0]?.[0]; + expect(task).toBeInstanceOf(SubagentTask); + expect((task as SubagentTask).model).toBe('cheap/fast'); }); it('binds the configured secondary model and reports it in the output and activity log', async () => { @@ -550,8 +552,8 @@ describe('TowerSpawnTool', () => { }); expect(result.isError).toBeUndefined(); - const task = registerTask.mock.calls[0]?.[0] as SubagentTask; - expect(task.description).toBe('M1 review: feat/build-gemm'); + const task = registerTask.mock.calls[0]?.[0]; + expect(task?.description).toBe('M1 review: feat/build-gemm'); }); it('describes the reviewer task with the reviewer name when the branch owns no mission', async () => { @@ -562,8 +564,8 @@ describe('TowerSpawnTool', () => { }); expect(result.isError).toBeUndefined(); - const task = registerTask.mock.calls[0]?.[0] as SubagentTask; - expect(task.description).toBe('review reviewer-b: feat/orphan-branch'); + const task = registerTask.mock.calls[0]?.[0]; + expect(task?.description).toBe('review reviewer-b: feat/orphan-branch'); }); it('refuses a duplicate name and points at a background resume', async () => { diff --git a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts index cc9ed3d66..adf5d48e8 100644 --- a/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/towerTools.test.ts @@ -564,7 +564,7 @@ describe('TowerSendTool + TowerInboxTool', () => { expect(busy.output).not.toContain('has no running task'); }); - it('stamps the sender token count from the usage service into the message frontmatter', async () => { + it('stamps the sender token_count from the usage service into the message frontmatter', async () => { usageTotal = { inputOther: 100, output: 50, inputCacheRead: 10, inputCacheCreation: 5 }; await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'metered', body: 'x' }); @@ -572,16 +572,16 @@ describe('TowerSendTool + TowerInboxTool', () => { const dir = join(repo, '.tower/comms/inbox'); const file = (await readdir(dir)).find((name) => name.includes('metered')); const { fields } = parseFrontmatter(await readFile(join(dir, file!), 'utf8')); - expect(fields['tokens']).toBe('165'); + expect(fields['token_count']).toBe('165'); }); - it('records tokens as -1 when the usage service reports nothing', async () => { + it('records token_count as -1 when the usage service reports nothing', async () => { await run(ix.get(ITowerSendTool), { to: 'w1', subject: 'unmetered', body: 'x' }); const dir = join(repo, '.tower/comms/inbox'); const file = (await readdir(dir)).find((name) => name.includes('unmetered')); const { fields } = parseFrontmatter(await readFile(join(dir, file!), 'utf8')); - expect(fields['tokens']).toBe('-1'); + expect(fields['token_count']).toBe('-1'); }); it('skips the delivery note for broadcasts and for sends from workers', async () => {