feat(composer): replicate WorkBuddy branch label and context usage (TODO 21)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 08:15:19 +08:00
1 parent cb8a7e6466
commit bde041c12d
21 files changed
+1350 -12

No files matched your search

+2
View File
@@ -97,6 +97,8 @@ export async function runTurn(options: RunTurnOptions): Promise<TurnResult> {
} else if (delta.type === 'reasoning') {
reasoning += delta.text
emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning } })
} else if (delta.type === 'usage') {
emit({ runId, type: 'usage', usage: delta.usage })
} else if (delta.type === 'tool_calls') {
for (const call of delta.calls) {
pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments })
+31 -1
View File
@@ -11,7 +11,7 @@
* - 取消(abort)→ ChatEvent `cancelled`
*/
import type { ChatEvent, ToolCallRecord } from '@shared/types'
import type { ChatEvent, TokenUsage, ToolCallRecord } from '@shared/types'
import { OmpKernel, type OmpFrame, type OmpToolInfo } from '../omp'
import { toolSpecs } from './tools'
import { executeTurnTool } from './conversation-tools'
@@ -169,6 +169,11 @@ export async function runOmpTurn(options: RunOmpTurnOptions): Promise<OmpTurnRes
if (isRecord(message) && message.role === 'assistant' && message.stopReason === 'error') {
failProvider(message.errorMessage)
}
// 真实上下文用量:OMP 把上游 usage 记在助手消息上(内核源码里的判定是
// `contextTokens ?? input + cacheRead + cacheWrite`,即本次请求真实消耗的上下文)。
// 拿不到就不发事件,绝不估算。
const usage = readOmpUsage(message)
if (usage) emit({ runId, type: 'usage', usage })
break
}
@@ -452,6 +457,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
/**
* OMP 助手消息上的真实 token 用量 → ChatEvent `usage`。
*
* 字段含义来自 OMP 内核自身(`omp.exe` 内 `contextTokens ?? input + cacheRead + cacheWrite`
* 即本次请求真实消耗的上下文;`totalTokens = input + output + cacheRead + cacheWrite`):
* - `input` 不含缓存命中,缓存命中单列在 `cacheRead`/`cacheWrite`。
* - 上游没给 usage、或本消息是中止/错误终态时返回 null,不产生事件、不估算。
*/
function readOmpUsage(message: unknown): TokenUsage | null {
if (!isRecord(message) || message.role !== 'assistant') return null
if (message.stopReason === 'aborted' || message.stopReason === 'error') return null
const usage = message.usage
if (!isRecord(usage)) return null
const count = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
const input = count(usage.input) ?? 0
const output = count(usage.output) ?? 0
const cacheRead = count(usage.cacheRead) ?? 0
const cacheWrite = count(usage.cacheWrite) ?? 0
const inputTokens = count(usage.contextTokens) ?? input + cacheRead + cacheWrite
if (inputTokens === 0 && output === 0) return null
const totalTokens = count(usage.totalTokens) ?? input + output + cacheRead + cacheWrite
return { inputTokens, outputTokens: output, totalTokens, ...(cacheRead > 0 ? { cachedTokens: cacheRead } : {}) }
}
function containsBinaryPayload(value: unknown, depth = 0): boolean {
if (depth > 4 || value === null || typeof value !== 'object') return false
if (
+2 -1
View File
@@ -10,7 +10,7 @@
* 模型路由、上下文裁剪、工具编排由上层(agent/loop.ts)负责。
*/
import type { GatewayConfig, ToolSpec } from '@shared/types'
import type { GatewayConfig, TokenUsage, ToolSpec } from '@shared/types'
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
import { parseSse } from './sse'
import { buildResponsesRequest, parseResponsesStream } from './responses'
@@ -34,6 +34,7 @@ export type GatewayDelta =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool_calls'; calls: WireToolCall[] }
| { type: 'usage'; usage: TokenUsage }
| { type: 'finish'; reason: string | null }
export interface GatewayStreamRequest {
+16
View File
@@ -149,6 +149,22 @@ export async function* parseResponsesStream(stream: ReadableStream<Uint8Array>,
const response = object(payload.response)
if (response.error || response.status !== 'completed' || !Array.isArray(response.output)) throw error('Responses 上游未返回合法成功终态')
for (let i = 0; i < response.output.length; i++) yield* output({ output_index: i }, object(response.output[i]), true)
const usage = object(response.usage)
const inputTokens = usage.input_tokens
const outputTokens = usage.output_tokens
const totalTokens = usage.total_tokens
if (typeof inputTokens === 'number' && typeof outputTokens === 'number' && typeof totalTokens === 'number') {
const cachedTokens = object(usage.input_tokens_details).cached_tokens
yield {
type: 'usage',
usage: {
inputTokens,
outputTokens,
totalTokens,
...(typeof cachedTokens === 'number' ? { cachedTokens } : {})
}
}
}
completed = true
break
}
+57
View File
@@ -0,0 +1,57 @@
import { execFile } from 'node:child_process'
import { promises as fs } from 'node:fs'
import { promisify } from 'node:util'
import type { GitBranchInfo } from '@shared/types'
const execFileAsync = promisify(execFile)
const GIT_TIMEOUT_MS = 5_000
const outsideRepository = (): GitBranchInfo => ({ repo: false, branch: null, detached: false, head: null })
function isUnavailableError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const candidate = error as NodeJS.ErrnoException & { killed?: boolean }
return candidate.code === 'ENOENT' || candidate.code === 'ETIMEDOUT' || candidate.killed === true
}
async function runGit(cwd: string, args: string[]): Promise<string> {
const result = await execFileAsync('git', ['-C', cwd, ...args], {
windowsHide: true,
timeout: GIT_TIMEOUT_MS,
encoding: 'utf8',
maxBuffer: 64 * 1024
})
return result.stdout.trim()
}
/** Read the Git branch for a workspace without allowing Git failures to escape to IPC. */
export async function readGitBranch(cwd: string): Promise<GitBranchInfo> {
try {
if (typeof cwd !== 'string' || cwd.length === 0) return outsideRepository()
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) return outsideRepository()
} catch {
return outsideRepository()
}
try {
if (await runGit(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return outsideRepository()
} catch (error) {
return isUnavailableError(error) ? { ...outsideRepository(), unavailable: true } : outsideRepository()
}
try {
const branch = await runGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
if (branch) return { repo: true, branch, detached: false, head: null }
} catch (error) {
if (isUnavailableError(error)) return { ...outsideRepository(), unavailable: true }
}
try {
const head = await runGit(cwd, ['rev-parse', '--short', 'HEAD'])
return { repo: true, branch: null, detached: true, head: head || null }
} catch (error) {
if (isUnavailableError(error)) return { ...outsideRepository(), unavailable: true }
return { repo: true, branch: null, detached: true, head: null }
}
}
+10 -1
View File
@@ -11,7 +11,7 @@ import { BrowserWindow, app, dialog, ipcMain, shell, type OpenDialogOptions } fr
import { randomUUID } from 'node:crypto'
import { unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ChatEvent, ChatMessage, WindowState } from '@shared/types'
import type { ChatEvent, ChatMessage, GitBranchInfo, WindowState } from '@shared/types'
import { freezeAssistantModelMetadata } from '@shared/assistant-model'
import type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc'
import { applyAssistantEvent } from '@shared/message-parts'
@@ -45,6 +45,7 @@ import * as experts from './capabilities/experts'
import { createCapabilitySnapshot } from './capabilities/runtime'
import type { McpServerInput } from '@shared/capabilities'
import { agentMode, agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
import { readGitBranch } from './git'
/** runId → 取消控制器 */
const activeRuns = new Map<string, AbortController>()
@@ -266,6 +267,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
if (sessionId) return (await sessions.getSessionContext(sessionId)).cwd ?? await config.getWorkspaceRoot()
return workspaces.workspaceRoot()
}
ipcMain.handle('git:branch', async (_event, sessionId: unknown): Promise<GitBranchInfo> => {
if (typeof sessionId !== 'string' || !sessionId.trim()) return { repo: false, branch: null, detached: false, head: null }
try {
return await readGitBranch(await sessionRoot(sessionId))
} catch {
return { repo: false, branch: null, detached: false, head: null }
}
})
ipcMain.handle('resources:openExternal', async (_event, input: string) => {
const url = new URL(input)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('链接协议不受支持')