diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index d681fd5..c2b9889 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -44,6 +44,7 @@ import { } from './executor-display-label.mjs'; import { applyCursorFirstAgentExecution } from './cursor-page-routing.mjs'; import { cursorDeepseekFallbackEnabled } from './cursor-agent-launch.mjs'; +import { buildCursorBillingTokenState } from './cursor-agent-usage.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -369,13 +370,83 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) { export function buildCodeRunCompletionReply(result) { const executor = resolveExecutorDisplayLabel(result?.executor, result?.executorLabel); - const output = sanitizeUserFacingBrandText(summarizeText(result?.stdout, 2400).trim()); + const rawOutput = result?.displayStdout ?? result?.stdout; + const output = sanitizeUserFacingBrandText(summarizeText(rawOutput, 2400).trim()); return [ `已由 ${executor} 完成执行,并通过平台文件验收。`, output ? `\n${output}` : '', ].join('').trim(); } +async function billCursorToolGatewayUsage({ + userAuth, + userId, + sessionId, + requestId, + usage, +}) { + if (!usage || !sessionId || !userAuth?.billSessionUsage) return null; + const prior = typeof userAuth.getBillingState === 'function' + ? await userAuth.getBillingState(sessionId).catch(() => null) + : null; + const tokenState = buildCursorBillingTokenState(usage, prior); + if (!tokenState) return null; + return userAuth.billSessionUsage(userId, sessionId, tokenState, requestId); +} + +function resolveCursorBillingSessionId({ deliverySessionId, agentSessionId, runId }) { + const delivery = String(deliverySessionId ?? '').trim(); + if (delivery) return delivery; + const session = String(agentSessionId ?? '').trim(); + if (session) return session; + const run = String(runId ?? '').trim(); + return run ? `h5run_${run}` : null; +} + +async function recordCursorToolGatewayBilling({ + appendEvent, + runId, + row, + result, + userAuth, + deliverySessionId = null, +}) { + if (String(result?.executor ?? '').trim().toLowerCase() !== 'cursor' || !result?.usage) { + return null; + } + const billingSessionId = resolveCursorBillingSessionId({ + deliverySessionId, + agentSessionId: row.agent_session_id, + runId, + }); + try { + const billing = await billCursorToolGatewayUsage({ + userAuth, + userId: row.user_id, + sessionId: billingSessionId, + requestId: row.request_id, + usage: result.usage, + }); + await appendEvent(runId, 'tool_gateway_billed', { + sessionId: billingSessionId, + executor: result.executor ?? null, + usage: result.usage, + billed: Boolean(billing?.ok), + costCents: billing?.costCents ?? 0, + deltaInputTokens: billing?.deltaInputTokens ?? 0, + deltaOutputTokens: billing?.deltaOutputTokens ?? 0, + }); + return billing; + } catch (err) { + await appendEvent(runId, 'tool_gateway_billing_failed', { + sessionId: billingSessionId, + executor: result.executor ?? null, + message: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + function normalizeExpectedFileCheck(value) { if (typeof value === 'string') { const expectedPath = value.trim(); @@ -1778,8 +1849,9 @@ export function createAgentRunGateway({ executor: result.executor ?? null, dryRun: Boolean(result.dryRun), exitCode: result.exitCode ?? null, - stdoutTail: summarizeText(result.stdout), + stdoutTail: summarizeText(result.displayStdout ?? result.stdout), stderrTail: summarizeText(result.stderr), + usage: result.usage ?? null, }); try { const validation = await validateToolGatewayResult({ @@ -1828,8 +1900,23 @@ export function createAgentRunGateway({ sessionId: delivery.sessionId, executor: result.executor ?? null, }); + await recordCursorToolGatewayBilling({ + appendEvent, + runId, + row, + result, + userAuth, + deliverySessionId: delivery.sessionId, + }); return { sessionId: delivery.sessionId, routing }; } + await recordCursorToolGatewayBilling({ + appendEvent, + runId, + row, + result, + userAuth, + }); return { sessionId: row.agent_session_id ?? null, routing }; } catch (err) { if (!(await fallbackCursorExecutorToDeepseek(err))) throw err; diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index b73c484..21478de 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -3131,6 +3131,98 @@ test('required Aider run persists a validated result into a chat session', async ); }); +test('required Cursor run bills usage after validated delivery', async () => { + const pool = createFakePool(); + const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-cursor-billing-')); + const deliveries = []; + const billingCalls = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: { + async resolveWorkingDir() { + return workdir; + }, + async getBillingState() { + return { lastInputTokens: 100, lastOutputTokens: 20 }; + }, + async billSessionUsage(userId, sessionId, tokenState, requestId) { + billingCalls.push({ userId, sessionId, tokenState, requestId }); + return { + ok: true, + costCents: 3, + deltaInputTokens: tokenState.accumulatedInputTokens - 100, + deltaOutputTokens: tokenState.accumulatedOutputTokens - 20, + }; + }, + }, + tkmindProxy: {}, + directChatService: { + async respondDeterministically(options) { + deliveries.push(options); + await options.onSessionReady('h5direct_cursor_result'); + return { sessionId: 'h5direct_cursor_result' }; + }, + }, + toolGateway: { + getStatus() { + return { + enabled: true, + protocol: 'agent-run-v1', + executors: ['cursor', 'aider', 'openhands'], + }; + }, + async executeJob() { + return { + ok: true, + dryRun: false, + executor: 'cursor', + exitCode: 0, + cwd: workdir, + stdout: '{"type":"result","subtype":"success","result":"done","usage":{"inputTokens":1000,"outputTokens":200}}', + displayStdout: 'done', + usage: { inputTokens: 1000, outputTokens: 200 }, + stderr: '', + }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-cursor-billing', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'build the page' }], + metadata: { + displayText: 'build the page', + memindRun: { + requiredExecutor: 'cursor', + }, + }, + }, + toolMode: 'code', + taskType: 'h5_chat_code_task', + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(deliveries.length, 1); + assert.match(deliveries[0].reply, /done/); + assert.equal(billingCalls.length, 1); + assert.equal(billingCalls[0].userId, 'user-1'); + assert.equal(billingCalls[0].sessionId, 'h5direct_cursor_result'); + assert.equal(billingCalls[0].requestId, 'req-cursor-billing'); + assert.deepEqual(billingCalls[0].tokenState, { + accumulatedInputTokens: 1100, + accumulatedOutputTokens: 220, + }); + assert.equal( + pool.events.some( + (event) => event.runId === run.id && event.eventType === 'tool_gateway_billed', + ), + true, + ); +}); + test('agent run fails non-retryably when tool gateway artifact validation fails', async () => { const pool = createFakePool(); const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-tool-validation-missing-')); diff --git a/cursor-agent-launch.mjs b/cursor-agent-launch.mjs index 5d34131..679c278 100644 --- a/cursor-agent-launch.mjs +++ b/cursor-agent-launch.mjs @@ -95,7 +95,7 @@ export function buildCursorExecutorLaunchPlan({ '--force', '--approve-mcps', '--output-format', - 'text', + 'stream-json', '--workspace', workspace, prompt, diff --git a/cursor-agent-launch.test.mjs b/cursor-agent-launch.test.mjs index 82457a6..f2f1f72 100644 --- a/cursor-agent-launch.test.mjs +++ b/cursor-agent-launch.test.mjs @@ -42,6 +42,8 @@ test('buildCursorExecutorLaunchPlan includes workspace and mindspace hints', asy assert.equal(plan.executor, 'cursor'); assert.equal(plan.cwd, '/tmp/mindspace/user-1'); assert.equal(plan.command, agentStub); + assert.ok(plan.args.includes('--output-format')); + assert.ok(plan.args.includes('stream-json')); assert.ok(plan.args.includes('--workspace')); assert.ok(plan.args.includes('/tmp/mindspace/user-1')); assert.match(plan.args.at(-1) ?? '', /public\/.*\.html/); diff --git a/cursor-agent-usage.mjs b/cursor-agent-usage.mjs new file mode 100644 index 0000000..faa0b4d --- /dev/null +++ b/cursor-agent-usage.mjs @@ -0,0 +1,90 @@ +function parseStreamJsonLine(line) { + const text = String(line ?? '').trim(); + if (!text) return null; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +export function parseCursorAgentStreamJsonEvents(stdout) { + const events = []; + for (const line of String(stdout ?? '').split('\n')) { + const parsed = parseStreamJsonLine(line); + if (parsed) events.push(parsed); + } + return events; +} + +export function normalizeCursorAgentUsage(raw) { + if (!raw || typeof raw !== 'object') return null; + const inputTokens = Number(raw.inputTokens ?? raw.input_tokens ?? 0); + const outputTokens = Number(raw.outputTokens ?? raw.output_tokens ?? 0); + const cacheReadTokens = Number(raw.cacheReadTokens ?? raw.cache_read_tokens ?? 0); + const cacheWriteTokens = Number(raw.cacheWriteTokens ?? raw.cache_write_tokens ?? 0); + const billableInput = inputTokens + cacheWriteTokens; + const billableOutput = outputTokens; + if (billableInput <= 0 && billableOutput <= 0) return null; + return { + inputTokens: billableInput, + outputTokens: billableOutput, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : 0, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : 0, + }; +} + +export function parseCursorAgentUsage(stdout) { + const events = parseCursorAgentStreamJsonEvents(stdout); + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type !== 'result') continue; + const usage = normalizeCursorAgentUsage(event.usage); + if (usage) return usage; + } + return null; +} + +function extractAssistantText(message) { + if (!message || typeof message !== 'object') return ''; + const content = message.content ?? message.message?.content; + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) return ''; + return content + .map((part) => { + if (!part || typeof part !== 'object') return ''; + if (part.type === 'text') return String(part.text ?? '').trim(); + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); +} + +export function extractCursorAgentDisplayText(stdout) { + const events = parseCursorAgentStreamJsonEvents(stdout); + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === 'result') { + const resultText = String(event.result ?? '').trim(); + if (resultText) return resultText; + } + if (event?.type === 'assistant') { + const assistantText = extractAssistantText(event.message ?? event); + if (assistantText) return assistantText; + } + } + return ''; +} + +export function buildCursorBillingTokenState(usage, priorBillingState = null) { + const normalized = normalizeCursorAgentUsage(usage); + if (!normalized) return null; + const priorIn = Number(priorBillingState?.lastInputTokens ?? 0); + const priorOut = Number(priorBillingState?.lastOutputTokens ?? 0); + return { + accumulatedInputTokens: priorIn + normalized.inputTokens, + accumulatedOutputTokens: priorOut + normalized.outputTokens, + }; +} diff --git a/cursor-agent-usage.test.mjs b/cursor-agent-usage.test.mjs new file mode 100644 index 0000000..b256c51 --- /dev/null +++ b/cursor-agent-usage.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildCursorBillingTokenState, + extractCursorAgentDisplayText, + normalizeCursorAgentUsage, + parseCursorAgentUsage, +} from './cursor-agent-usage.mjs'; + +const SAMPLE_STREAM_JSON = [ + '{"type":"system","subtype":"init","cwd":"/tmp","session_id":"abc","model":"Auto"}', + '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"reply with exactly: ok"}]}}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', + '{"type":"result","subtype":"success","duration_ms":13064,"result":"ok","usage":{"inputTokens":7765,"outputTokens":64,"cacheReadTokens":5427,"cacheWriteTokens":0}}', +].join('\n'); + +test('parseCursorAgentUsage reads result usage from stream-json stdout', () => { + const usage = parseCursorAgentUsage(SAMPLE_STREAM_JSON); + assert.deepEqual(usage, { + inputTokens: 7765, + outputTokens: 64, + cacheReadTokens: 5427, + cacheWriteTokens: 0, + }); +}); + +test('normalizeCursorAgentUsage includes cache write tokens in billable input', () => { + const usage = normalizeCursorAgentUsage({ + inputTokens: 1000, + outputTokens: 200, + cacheWriteTokens: 300, + }); + assert.deepEqual(usage, { + inputTokens: 1300, + outputTokens: 200, + cacheReadTokens: 0, + cacheWriteTokens: 300, + }); +}); + +test('extractCursorAgentDisplayText prefers result text', () => { + assert.equal(extractCursorAgentDisplayText(SAMPLE_STREAM_JSON), 'ok'); +}); + +test('buildCursorBillingTokenState accumulates on prior session billing', () => { + const tokenState = buildCursorBillingTokenState( + { inputTokens: 1000, outputTokens: 200 }, + { lastInputTokens: 5000, lastOutputTokens: 800 }, + ); + assert.deepEqual(tokenState, { + accumulatedInputTokens: 6000, + accumulatedOutputTokens: 1000, + }); +}); diff --git a/executor-display-label.test.mjs b/executor-display-label.test.mjs index b642bb9..0c8db8b 100644 --- a/executor-display-label.test.mjs +++ b/executor-display-label.test.mjs @@ -15,13 +15,14 @@ test('buildCodeRunCompletionReply never exposes cursor brand to users', () => { assert.doesNotMatch(reply, /cursor/i); }); -test('buildCodeRunCompletionReply sanitizes cursor wording in stdout', () => { +test('buildCodeRunCompletionReply prefers displayStdout over raw stream-json stdout', () => { const reply = buildCodeRunCompletionReply({ executor: 'cursor', - stdout: 'cursor agent finished editing public/page.html', + stdout: '{"type":"result","result":"hidden"}\n', + displayStdout: '已写入 public/spring-poem.html', }); - assert.match(reply, /TKMind 智趣 finished editing public\/page\.html/); - assert.doesNotMatch(reply, /\bcursor\b/i); + assert.match(reply, /已写入 public\/spring-poem\.html/); + assert.doesNotMatch(reply, /"type":"result"/); }); test('sanitizeUserFacingBrandText replaces cursor wording', () => { diff --git a/package.json b/package.json index b6f0644..043da3a 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run", "verify:template-catalog-portal": "node scripts/verify-template-catalog-portal.mjs", "verify:template-catalog-e2e": "node scripts/verify-template-catalog-e2e.mjs", - "verify:cursor-executor": "node --test cursor-agent-launch.test.mjs cursor-page-routing.test.mjs tool-gateway.test.mjs llm-providers.test.mjs help-escalation.test.mjs executor-display-label.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs wechat-cursor-executor-policy.test.mjs", + "verify:cursor-executor": "node --test cursor-agent-launch.test.mjs cursor-agent-usage.test.mjs cursor-page-routing.test.mjs tool-gateway.test.mjs llm-providers.test.mjs help-escalation.test.mjs executor-display-label.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs wechat-cursor-executor-policy.test.mjs", "repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs", "repair:page-data:103": "node scripts/ensure-page-data-datasets.mjs && node scripts/repair-page-data-workspace-bindings.mjs", "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", diff --git a/tool-gateway.mjs b/tool-gateway.mjs index 364cc85..2906313 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -3,6 +3,10 @@ import { EventEmitter } from 'node:events'; import fs from 'node:fs/promises'; import path from 'node:path'; import { buildCursorExecutorLaunchPlan } from './cursor-agent-launch.mjs'; +import { + extractCursorAgentDisplayText, + parseCursorAgentUsage, +} from './cursor-agent-usage.mjs'; import { resolveExecutorDisplayLabel } from './executor-display-label.mjs'; const BASE_CODE_EXECUTORS = ['aider', 'openhands']; @@ -308,6 +312,10 @@ export function createToolGateway({ child.on('exit', (code, signal) => { cleanup(); if (code === 0) { + const usage = executor === 'cursor' ? parseCursorAgentUsage(stdout) : null; + const displayStdout = executor === 'cursor' + ? extractCursorAgentDisplayText(stdout) + : ''; resolve({ ok: true, executor, @@ -319,6 +327,8 @@ export function createToolGateway({ exitCode: code, signal: signal ?? null, stdout, + displayStdout: displayStdout || undefined, + usage: usage ?? undefined, stderr, }); return;