feat(composer): replicate WorkBuddy branch label and context usage (TODO 21)
This commit is contained in:
1 parent
cb8a7e6466
commit
bde041c12d
21 files changed
+1350
-12
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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('链接协议不受支持')
|
||||
|
||||
Reference in new issue
Block a user