🐛 fix(codex): persist model metadata and file diffs (#15672)

* 🐛 fix(codex): persist model metadata

* 🐛 fix(codex): show file change diffs
This commit is contained in:
Arvin Xu
2026-06-11 19:15:45 +08:00
committed by GitHub
parent dbc8d76c8d
commit e20496e444
16 changed files with 915 additions and 62 deletions
@@ -18,13 +18,15 @@ import {
} from '@lobechat/electron-client-ipc';
import type { AskUserBridge } from '@lobechat/heterogeneous-agents/askUser';
import { AskUserMcpServer } from '@lobechat/heterogeneous-agents/askUser';
import type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
import type { AgentContentBlock, AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
import {
AgentStreamPipeline,
buildAgentInput,
materializeImageToPath,
normalizeImage,
readCodexSessionModel,
resolveCliSpawnPlan,
resolveCodexInitialModel,
} from '@lobechat/heterogeneous-agents/spawn';
import { app as electronApp, BrowserWindow } from 'electron';
@@ -176,9 +178,18 @@ interface AgentSession {
command: string;
cwd?: string;
env?: Record<string, string>;
model?: string;
modelSource?: string;
modelVerificationLastAttemptAt?: number;
modelVerificationLastAttemptSessionId?: string;
process?: ChildProcess;
resumeSessionId?: string;
sessionId: string;
verifiedModel?: string;
verifiedModelContextWindow?: number;
verifiedModelProvider?: string;
verifiedModelSessionId?: string;
verifiedModelSourceFile?: string;
}
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
@@ -581,12 +592,19 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
createdAt: createdAt.toISOString(),
cwd,
envKeys: session.env ? Object.keys(session.env).sort() : [],
model: session.model,
modelSource: session.modelSource,
resumeSessionId: session.resumeSessionId,
sessionId: session.sessionId,
stdinBytes: stdinPayload === undefined ? 0 : Buffer.byteLength(stdinPayload),
stdinFile: stdinPayload === undefined ? undefined : 'stdin.txt',
stderrFile: 'stderr.log',
stdoutFile: 'stdout.jsonl',
verifiedModel: session.verifiedModel,
verifiedModelContextWindow: session.verifiedModelContextWindow,
verifiedModelProvider: session.verifiedModelProvider,
verifiedModelSessionId: session.verifiedModelSessionId,
verifiedModelSourceFile: session.verifiedModelSourceFile,
},
null,
2,
@@ -888,6 +906,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
let spawnPlan;
let traceSession;
let cwd: string;
let spawnEnv: NodeJS.ProcessEnv;
try {
const driver = getHeterogeneousAgentDriver(session.agentType);
spawnPlan = await driver.buildSpawnPlan({
@@ -906,6 +925,23 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
cwd = session.cwd || electronApp.getPath('desktop');
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
spawnEnv = { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env };
if (session.agentType === 'codex') {
const initialModel = await resolveCodexInitialModel({
args: spawnPlan.args,
env: spawnEnv,
});
if (initialModel?.model) {
session.model = initialModel.model;
session.modelSource = initialModel.source;
}
}
traceSession = await this.createCliTraceSession({
cliArgs: spawnPlan.args,
cwd,
@@ -940,29 +976,27 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// the claude binary can leave bash/grep/etc. tool children running and
// the CLI hung waiting on them. Windows has different semantics — use
// taskkill /T /F there; no detached flag needed.
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const spawnOptions = {
cwd,
detached: process.platform !== 'win32',
// Strip host Anthropic creds from the inherited env so a developer's
// shell `ANTHROPIC_API_KEY` can't hijack the CLI's own auth. `session.env`
// is spread last, so an agent that explicitly configures a key still wins.
env: { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env },
env: spawnEnv,
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
};
return new Promise<void>((resolve, reject) => {
const proc = spawn(resolvedCliSpawnPlan.command, resolvedCliSpawnPlan.args, spawnOptions);
this.handleSpawnedAgentProcess({
cwd,
intervention,
params,
proc,
reject,
resolve,
session,
spawnEnv,
traceSession,
useStdin,
spawnPlan,
@@ -970,23 +1004,86 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
});
}
private async verifyCodexSessionModel({
env,
pipeline,
session,
traceSession,
}: {
env: NodeJS.ProcessEnv;
pipeline: AgentStreamPipeline;
session: AgentSession;
traceSession: CliTraceSession | undefined;
}): Promise<AgentStreamEvent[]> {
if (
session.agentType !== 'codex' ||
!pipeline.sessionId ||
session.verifiedModelSessionId === pipeline.sessionId
) {
return [];
}
const now = Date.now();
if (
session.modelVerificationLastAttemptSessionId === pipeline.sessionId &&
session.modelVerificationLastAttemptAt &&
now - session.modelVerificationLastAttemptAt < 1000
) {
return [];
}
session.modelVerificationLastAttemptSessionId = pipeline.sessionId;
session.modelVerificationLastAttemptAt = now;
const sessionModel = await readCodexSessionModel(pipeline.sessionId, { env });
if (!sessionModel?.model) return [];
const previousModel = session.model;
session.verifiedModel = sessionModel.model;
session.verifiedModelContextWindow = sessionModel.contextWindow;
session.verifiedModelProvider = sessionModel.provider;
session.verifiedModelSessionId = pipeline.sessionId;
session.verifiedModelSourceFile = sessionModel.sourceFile;
void this.writeCliTraceJson(traceSession, 'model.json', {
initialModel: previousModel,
initialModelSource: session.modelSource,
sessionId: pipeline.sessionId,
verifiedAt: new Date().toISOString(),
verifiedContextWindow: sessionModel.contextWindow,
verifiedLine: sessionModel.line,
verifiedModel: sessionModel.model,
verifiedModelProvider: sessionModel.provider,
verifiedSourceFile: sessionModel.sourceFile,
});
if (previousModel === sessionModel.model) return [];
session.model = sessionModel.model;
session.modelSource = 'codex-session';
return pipeline.configureSession({ model: sessionModel.model });
}
private handleSpawnedAgentProcess({
cwd,
intervention,
params,
proc,
reject,
resolve,
session,
spawnEnv,
spawnPlan,
traceSession,
useStdin,
}: {
cwd: string;
intervention?: Awaited<ReturnType<HeterogeneousAgentCtr['setupInterventionForOp']>>;
params: SendPromptParams;
proc: ChildProcess;
reject: (reason?: unknown) => void;
resolve: () => void;
session: AgentSession;
spawnEnv: NodeJS.ProcessEnv;
spawnPlan: HeterogeneousAgentBuildPlan;
traceSession: CliTraceSession | undefined;
useStdin: boolean;
@@ -1021,10 +1118,12 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// toStreamEvent all run inside the shared pipeline, so renderer + future
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
// no per-consumer adapter. The pipeline auto-wires the Codex
// file-change line-stat tracker when `agentType === 'codex'`, so this
// file-change diff/stat tracker when `agentType === 'codex'`, so this
// controller stays agent-agnostic.
const pipeline = new AgentStreamPipeline({
agentType: session.agentType,
cwd,
initialModel: session.model,
operationId: params.operationId,
});
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
@@ -1039,6 +1138,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
session.agentSessionId = pipeline.sessionId;
}
events.push(
...(await this.verifyCodexSessionModel({
env: spawnEnv,
pipeline,
session,
traceSession,
})),
);
for (const event of events) {
this.broadcast('heteroAgentEvent', {
event,
@@ -2,7 +2,7 @@
import { FilePathDisplay } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Text } from '@lobehub/ui';
import { Flexbox, PatchDiff, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -60,6 +60,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
padding-block: 2px;
padding-inline: 4px;
`,
patch: css`
overflow: hidden;
padding-block-end: 8px;
padding-inline-start: 16px;
`,
rowMain: css`
display: flex;
flex: 1;
@@ -88,6 +93,19 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
}));
const getFileName = (filePath: string): string => {
if (!filePath) return '';
const normalized = filePath.replaceAll('\\', '/');
return normalized.split('/').findLast(Boolean) || filePath;
};
const getFileLanguage = (filePath: string): string | undefined => {
const fileName = getFileName(filePath);
const index = fileName.lastIndexOf('.');
if (index < 0 || index === fileName.length - 1) return;
return fileName.slice(index + 1).toLowerCase();
};
const getKindClassName = (kind: CodexFileChangeKind) => {
switch (kind) {
case 'added': {
@@ -140,22 +158,36 @@ const FileChangeRender = memo<BuiltinRenderProps<CodexFileChangeArgs, CodexFileC
const path = change.path || '';
return (
<Flexbox horizontal className={styles.row} key={`${path}-${index}`}>
<span className={cx(styles.kindDot, getKindClassName(kind))} />
<div className={styles.rowMain}>
<div className={styles.path}>
{path ? (
<FilePathDisplay filePath={path} />
) : (
<Text className={styles.unknownPath}>
{t('builtins.codex.fileChange.unknownFile', {
defaultValue: 'Unknown file',
})}
</Text>
)}
<Flexbox key={`${path}-${index}`}>
<Flexbox horizontal className={styles.row}>
<span className={cx(styles.kindDot, getKindClassName(kind))} />
<div className={styles.rowMain}>
<div className={styles.path}>
{path ? (
<FilePathDisplay filePath={path} />
) : (
<Text className={styles.unknownPath}>
{t('builtins.codex.fileChange.unknownFile', {
defaultValue: 'Unknown file',
})}
</Text>
)}
</div>
<LineStats linesAdded={change.linesAdded} linesDeleted={change.linesDeleted} />
</div>
<LineStats linesAdded={change.linesAdded} linesDeleted={change.linesDeleted} />
</div>
</Flexbox>
{change.diffText && (
<div className={styles.patch}>
<PatchDiff
fileName={getFileName(path)}
language={getFileLanguage(path)}
patch={change.diffText}
showHeader={false}
variant="borderless"
viewMode="unified"
/>
</div>
)}
</Flexbox>
);
})}
@@ -14,6 +14,7 @@ export interface CodexTodoListArgs {
export type CodexFileChangeKind = 'added' | 'deleted' | 'modified' | 'renamed';
export interface CodexFileChangeEntry {
diffText?: string;
kind?: string;
linesAdded?: number;
linesDeleted?: number;
@@ -26,6 +27,7 @@ export interface CodexFileChangeArgs {
export interface CodexFileChangeState {
changes?: CodexFileChangeEntry[];
diffText?: string;
linesAdded?: number;
linesDeleted?: number;
}
@@ -48,6 +48,37 @@ describe('CodexAdapter', () => {
});
});
it('emits model metadata when the host configures the Codex session', () => {
const adapter = new CodexAdapter();
const metadata = adapter.adapt({
model: 'gpt-5.5',
type: 'session_configured',
});
const start = adapter.adapt({ type: 'turn.started' });
expect(metadata).toHaveLength(1);
expect(metadata[0]).toMatchObject({
data: {
model: 'gpt-5.5',
phase: 'turn_metadata',
provider: 'codex',
},
type: 'step_complete',
});
expect(start[0]).toMatchObject({
data: { model: 'gpt-5.5', provider: 'codex' },
type: 'stream_start',
});
});
it('deduplicates repeated host session model metadata', () => {
const adapter = new CodexAdapter();
expect(adapter.adapt({ model: 'gpt-5.5', type: 'session_configured' })).toHaveLength(1);
expect(adapter.adapt({ model: 'gpt-5.5', type: 'session_configured' })).toEqual([]);
});
it('emits terminal errors from Codex JSONL error events', () => {
const adapter = new CodexAdapter();
const rawMessage = JSON.stringify({
@@ -387,6 +418,8 @@ describe('CodexAdapter', () => {
it('maps file_change items into readable tool results', () => {
const adapter = new CodexAdapter();
const diffText =
'diff --git a/private/tmp/codex-file-change-sample.txt b/private/tmp/codex-file-change-sample.txt\n--- /dev/null\n+++ b/private/tmp/codex-file-change-sample.txt\n@@ -0,0 +1,3 @@\n+line one\n+line two\n+line three\n';
const started = adapter.adapt({
item: {
@@ -401,12 +434,14 @@ describe('CodexAdapter', () => {
item: {
changes: [
{
diffText,
kind: 'add',
linesAdded: 3,
linesDeleted: 0,
path: '/private/tmp/codex-file-change-sample.txt',
},
],
diffText,
id: 'item_1',
linesAdded: 3,
linesDeleted: 0,
@@ -436,12 +471,14 @@ describe('CodexAdapter', () => {
pluginState: {
changes: [
{
diffText,
kind: 'add',
linesAdded: 3,
linesDeleted: 0,
path: '/private/tmp/codex-file-change-sample.txt',
},
],
diffText,
linesAdded: 3,
linesDeleted: 0,
},
@@ -3,6 +3,7 @@ import type {
HeterogeneousAgentEvent,
HeterogeneousTerminalErrorData,
StepCompleteData,
StreamStartData,
ToolCallPayload,
ToolResultData,
UsageData,
@@ -37,6 +38,7 @@ interface CodexTodoListItem extends CodexBaseItem {
}
interface CodexFileChangeEntry {
diffText?: string;
kind?: string;
linesAdded?: number;
linesDeleted?: number;
@@ -45,6 +47,7 @@ interface CodexFileChangeEntry {
interface CodexFileChangeItem extends CodexBaseItem {
changes?: CodexFileChangeEntry[];
diffText?: string;
linesAdded?: number;
linesDeleted?: number;
}
@@ -158,6 +161,7 @@ const synthesizeTodoListPluginState = (item: CodexTodoListItem) => {
const synthesizeFileChangePluginState = (item: CodexFileChangeItem) => {
const changes = (item.changes || []).map((change) => ({
...(change.diffText ? { diffText: change.diffText } : {}),
kind: change.kind,
linesAdded: change.linesAdded ?? 0,
linesDeleted: change.linesDeleted ?? 0,
@@ -170,6 +174,7 @@ const synthesizeFileChangePluginState = (item: CodexFileChangeItem) => {
return {
changes,
...(item.diffText ? { diffText: item.diffText } : {}),
linesAdded: item.linesAdded ?? 0,
linesDeleted: item.linesDeleted ?? 0,
};
@@ -619,9 +624,16 @@ export class CodexAdapter implements AgentEventAdapter {
private handleSessionConfigured(raw: any): HeterogeneousAgentEvent[] {
const model = getEventModel(raw);
if (model) this.currentModel = model;
if (!model || model === this.currentModel) return [];
return [];
this.currentModel = model;
return [
this.makeEvent('step_complete', {
model,
phase: 'turn_metadata',
provider: CODEX_IDENTIFIER,
} satisfies StepCompleteData),
];
}
private handleTurnStarted(): HeterogeneousAgentEvent[] {
@@ -632,13 +644,13 @@ export class CodexAdapter implements AgentEventAdapter {
if (!this.started) {
this.started = true;
return [this.makeEvent('stream_start', { provider: CODEX_IDENTIFIER })];
return [this.makeEvent('stream_start', this.getStreamStartData())];
}
this.stepIndex += 1;
return [
this.makeEvent('stream_end', {}),
this.makeEvent('stream_start', { newStep: true, provider: CODEX_IDENTIFIER }),
this.makeEvent('stream_start', this.getStreamStartData({ newStep: true })),
];
}
@@ -671,7 +683,7 @@ export class CodexAdapter implements AgentEventAdapter {
this.resetStepToolCalls();
this.hasTextInCurrentStep = false;
events.push(this.makeEvent('stream_end', {}));
events.push(this.makeEvent('stream_start', { newStep: true, provider: CODEX_IDENTIFIER }));
events.push(this.makeEvent('stream_start', this.getStreamStartData({ newStep: true })));
}
const content =
@@ -754,6 +766,14 @@ export class CodexAdapter implements AgentEventAdapter {
this.stepToolCallIds.clear();
}
private getStreamStartData(extra: Record<string, unknown> = {}): StreamStartData {
return {
...(this.currentModel ? { model: this.currentModel } : {}),
provider: CODEX_IDENTIFIER,
...extra,
};
}
private makeEvent(type: HeterogeneousAgentEvent['type'], data: any): HeterogeneousAgentEvent {
return {
data,
@@ -58,6 +58,32 @@ describe('AgentStreamPipeline', () => {
expect((codex as any).codexTracker).toBeDefined();
});
it('emits an initial Codex model metadata event before stdout-derived events', async () => {
const pipeline = new AgentStreamPipeline({
agentType: 'codex',
initialModel: 'gpt-5.5',
operationId: 'op-codex',
});
const events = await pipeline.push(`${JSON.stringify({ type: 'turn.started' })}\n`);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
data: {
model: 'gpt-5.5',
phase: 'turn_metadata',
provider: 'codex',
},
operationId: 'op-codex',
type: 'step_complete',
});
expect(events[1]).toMatchObject({
data: { model: 'gpt-5.5', provider: 'codex' },
operationId: 'op-codex',
type: 'stream_start',
});
});
it('drops non-JSON noise lines instead of throwing', async () => {
const pipeline = new AgentStreamPipeline({
agentType: 'claude-code',
@@ -1,7 +1,7 @@
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
import { createAdapter } from '../registry';
import type { AgentEventAdapter } from '../types';
import type { AgentEventAdapter, HeterogeneousAgentEvent } from '../types';
import { CodexFileChangeTracker } from './codexFileChangeTracker';
import { JsonlStreamProcessor } from './jsonlProcessor';
import { toStreamEvent } from './streamEvent';
@@ -9,6 +9,10 @@ import { toStreamEvent } from './streamEvent';
export interface AgentStreamPipelineOptions {
/** Agent type key (e.g. `claude-code`, `codex`). */
agentType: string;
/** Working directory used to resolve relative file paths emitted by CLI tools. */
cwd?: string;
/** Host-known model to emit before the CLI's first stdout payload. */
initialModel?: string;
/** Operation id to stamp onto every emitted `AgentStreamEvent`. */
operationId: string;
}
@@ -22,7 +26,7 @@ export interface AgentStreamPipelineOptions {
*
* Both the desktop main process and the future `lh hetero exec` CLI feed
* stdout into this pipeline so consumers (renderer / server) only ever see a
* single, unified wire shape. Codex's file-change line-stat enrichment is
* single, unified wire shape. Codex's file-change diff/stat enrichment is
* baked in here so consumers don't need to know it exists.
*/
export class AgentStreamPipeline {
@@ -30,11 +34,17 @@ export class AgentStreamPipeline {
private readonly adapter: AgentEventAdapter;
private readonly operationId: string;
private readonly codexTracker?: CodexFileChangeTracker;
private queuedEvents: AgentStreamEvent[] = [];
constructor(options: AgentStreamPipelineOptions) {
this.adapter = createAdapter(options.agentType);
this.operationId = options.operationId;
this.codexTracker = options.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
this.codexTracker =
options.agentType === 'codex' ? new CodexFileChangeTracker(options.cwd) : undefined;
if (options.initialModel) {
this.queuedEvents.push(...this.configureSession({ model: options.initialModel }));
}
}
/** CC/Codex session id extracted by the underlying adapter (`adapter.sessionId`). */
@@ -45,7 +55,7 @@ export class AgentStreamPipeline {
/**
* Push a stdout chunk through the pipeline. Resolves with the resulting
* `AgentStreamEvent` batch in arrival order. Async because the codex
* tracker reads pre-edit file snapshots from disk for diff stats.
* tracker reads pre-edit file snapshots from disk for diffs and line stats.
*/
async push(chunk: Buffer | string): Promise<AgentStreamEvent[]> {
return this.processPayloads(this.processor.push(chunk));
@@ -61,16 +71,33 @@ export class AgentStreamPipeline {
return [...trailing, ...flushed];
}
configureSession(data: { model?: string }): AgentStreamEvent[] {
return this.toStreamEvents(
this.adapter.adapt({
...data,
type: 'session_configured',
}),
);
}
private async processPayloads(payloads: unknown[]): Promise<AgentStreamEvent[]> {
const out: AgentStreamEvent[] = [];
const out: AgentStreamEvent[] = this.drainQueuedEvents();
for (const raw of payloads) {
const payload = this.codexTracker ? await this.codexTracker.track(raw as any) : raw;
for (const event of this.adapter.adapt(payload)) {
out.push(toStreamEvent(event, this.operationId));
}
out.push(...this.toStreamEvents(this.adapter.adapt(payload)));
}
return out;
}
private drainQueuedEvents(): AgentStreamEvent[] {
const events = this.queuedEvents;
this.queuedEvents = [];
return events;
}
private toStreamEvents(events: HeterogeneousAgentEvent[]): AgentStreamEvent[] {
return events.map((event) => toStreamEvent(event, this.operationId));
}
}
@@ -1,4 +1,4 @@
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -14,7 +14,7 @@ describe('CodexFileChangeTracker', () => {
tempDirs.length = 0;
});
it('enriches completed file_change payloads with per-file and total line stats', async () => {
it('enriches completed file_change payloads with per-file diffs and total line stats', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
tempDirs.push(dir);
@@ -55,12 +55,14 @@ describe('CodexFileChangeTracker', () => {
expect(enriched.item).toMatchObject({
changes: [
{
diffText: expect.stringContaining('+appended line'),
kind: 'update',
linesAdded: 1,
linesDeleted: 0,
path: updatePath,
},
{
diffText: expect.stringContaining('+line two'),
kind: 'add',
linesAdded: 2,
linesDeleted: 0,
@@ -70,6 +72,8 @@ describe('CodexFileChangeTracker', () => {
linesAdded: 3,
linesDeleted: 0,
});
expect((enriched.item as any).diffText).toContain(`diff --git a${updatePath} b${updatePath}`);
expect((enriched.item as any).diffText).toContain(`diff --git a${addPath} b${addPath}`);
});
it('treats rename changes as metadata-only and keeps line stats at zero', async () => {
@@ -108,6 +112,53 @@ describe('CodexFileChangeTracker', () => {
linesAdded: 0,
linesDeleted: 0,
});
expect(enriched.item).not.toHaveProperty('diffText');
});
it('resolves relative file_change paths from the configured cwd', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
tempDirs.push(dir);
const relativePath = 'nested/relative.txt';
const absolutePath = path.join(dir, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, 'before\n', 'utf8');
const tracker = new CodexFileChangeTracker(dir);
await tracker.track({
item: {
changes: [{ kind: 'update', path: relativePath }],
id: 'item_relative',
type: 'file_change',
},
type: 'item.started',
});
await writeFile(absolutePath, 'before\nafter\n', 'utf8');
const enriched = await tracker.track({
item: {
changes: [{ kind: 'update', path: relativePath }],
id: 'item_relative',
type: 'file_change',
},
type: 'item.completed',
});
expect(enriched.item).toMatchObject({
changes: [
{
diffText: expect.stringContaining(`diff --git a/${relativePath} b/${relativePath}`),
linesAdded: 1,
linesDeleted: 0,
path: relativePath,
},
],
linesAdded: 1,
linesDeleted: 0,
});
});
it('counts added lines even when file content begins with repeated plus markers', async () => {
@@ -138,7 +189,15 @@ describe('CodexFileChangeTracker', () => {
});
expect(enriched.item).toMatchObject({
changes: [{ kind: 'add', linesAdded: 2, linesDeleted: 0, path: addPath }],
changes: [
{
diffText: expect.stringContaining('++++header lookalike'),
kind: 'add',
linesAdded: 2,
linesDeleted: 0,
path: addPath,
},
],
linesAdded: 2,
linesDeleted: 0,
});
@@ -1,8 +1,10 @@
import { access, readFile } from 'node:fs/promises';
import path from 'node:path';
import { createPatch } from 'diff';
interface CodexFileChangeEntry {
diffText?: string;
kind?: string;
path?: string;
}
@@ -32,10 +34,15 @@ interface CodexFileChangeLineStats {
linesDeleted: number;
}
interface CodexTrackedFileChangeEntry extends CodexFileChangeEntry, CodexFileChangeLineStats {}
interface CodexFileChangeDiff extends CodexFileChangeLineStats {
diffText?: string;
}
interface CodexTrackedFileChangeEntry extends CodexFileChangeEntry, CodexFileChangeDiff {}
interface CodexTrackedFileChangeItem extends CodexFileChangeItem, CodexFileChangeLineStats {
changes?: CodexTrackedFileChangeEntry[];
diffText?: string;
}
const isCodexFileChangePayload = (
@@ -60,13 +67,10 @@ const readTextFileSnapshot = async (filePath: string): Promise<CodexFileChangeSn
}
};
const countPatchLines = (
previousContent: string,
nextContent: string,
): CodexFileChangeLineStats => {
if (previousContent === nextContent) return { linesAdded: 0, linesDeleted: 0 };
const resolveFilePath = (filePath: string, cwd: string): string =>
path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
const patch = createPatch('codex-file-change', previousContent, nextContent, '', '');
const countPatchLines = (patch: string): CodexFileChangeLineStats => {
let insideHunk = false;
let linesAdded = 0;
let linesDeleted = 0;
@@ -92,38 +96,70 @@ const countPatchLines = (
return { linesAdded, linesDeleted };
};
const computeLineStats = async (
const toGitDiffPath = (prefix: 'a' | 'b', filePath: string): string =>
filePath.startsWith('/') ? `${prefix}${filePath}` : `${prefix}/${filePath}`;
const createDiffText = (filePath: string, previousContent: string, nextContent: string): string => {
const patch = createPatch(filePath, previousContent, nextContent, '', '');
return `diff --git ${toGitDiffPath('a', filePath)} ${toGitDiffPath('b', filePath)}\n${patch}`;
};
const buildFileChangeDiff = (
filePath: string,
previousContent: string,
nextContent: string,
): CodexFileChangeDiff => {
if (previousContent === nextContent) return { linesAdded: 0, linesDeleted: 0 };
const diffText = createDiffText(filePath, previousContent, nextContent);
return {
...countPatchLines(diffText),
diffText,
};
};
const computeFileChangeDiff = async (
change: CodexFileChangeEntry,
cwd: string,
snapshot?: CodexFileChangeSnapshot,
): Promise<CodexFileChangeLineStats> => {
): Promise<CodexFileChangeDiff> => {
const filePath = change.path;
if (!filePath) return { linesAdded: 0, linesDeleted: 0 };
const kind = change.kind ?? 'update';
if (kind === 'rename') return { linesAdded: 0, linesDeleted: 0 };
const resolvedFilePath = resolveFilePath(filePath, cwd);
const previousContent = snapshot?.content ?? '';
const current = await readTextFileSnapshot(filePath);
const current = await readTextFileSnapshot(resolvedFilePath);
const nextContent = current.content ?? '';
if (kind === 'add') {
if (!current.exists) return { linesAdded: 0, linesDeleted: 0 };
return countPatchLines('', nextContent);
if (current.content === undefined) return { linesAdded: 0, linesDeleted: 0 };
return buildFileChangeDiff(filePath, '', nextContent);
}
if (kind === 'delete' || kind === 'remove') {
if (!snapshot?.exists) return { linesAdded: 0, linesDeleted: 0 };
return countPatchLines(previousContent, '');
if (snapshot.content === undefined) return { linesAdded: 0, linesDeleted: 0 };
return buildFileChangeDiff(filePath, previousContent, '');
}
if (!snapshot?.exists && !current.exists) return { linesAdded: 0, linesDeleted: 0 };
return countPatchLines(previousContent, nextContent);
if (snapshot?.exists && snapshot.content === undefined) return { linesAdded: 0, linesDeleted: 0 };
if (current.exists && current.content === undefined) return { linesAdded: 0, linesDeleted: 0 };
return buildFileChangeDiff(filePath, previousContent, nextContent);
};
export class CodexFileChangeTracker {
private snapshots = new Map<string, Map<string, CodexFileChangeSnapshot>>();
constructor(private readonly cwd = process.cwd()) {}
async track<T extends CodexFileChangePayload>(payload: T): Promise<T> {
if (!isCodexFileChangePayload(payload)) return payload;
@@ -136,7 +172,10 @@ export class CodexFileChangeTracker {
await Promise.all(
changes.map(async (change) => {
if (!change.path || snapshots.has(change.path)) return;
snapshots.set(change.path, await readTextFileSnapshot(change.path));
snapshots.set(
change.path,
await readTextFileSnapshot(resolveFilePath(change.path, this.cwd)),
);
}),
);
@@ -153,14 +192,15 @@ export class CodexFileChangeTracker {
const trackedChanges = await Promise.all(
changes.map(async (change) => {
const stats = await computeLineStats(
const diff = await computeFileChangeDiff(
change,
this.cwd,
change.path ? snapshots.get(change.path) : undefined,
);
return {
...change,
...stats,
...diff,
} satisfies CodexTrackedFileChangeEntry;
}),
);
@@ -172,11 +212,16 @@ export class CodexFileChangeTracker {
}),
{ linesAdded: 0, linesDeleted: 0 },
);
const diffText = trackedChanges
.map((change) => change.diffText)
.filter((text): text is string => !!text)
.join('\n');
return {
...payload,
item: {
...payload.item,
...(diffText ? { diffText } : {}),
...totals,
changes: trackedChanges,
} satisfies CodexTrackedFileChangeItem,
@@ -0,0 +1,112 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
parseCodexModelFromArgs,
parseCodexProfileFromArgs,
readCodexSessionModel,
resolveCodexInitialModel,
} from './codexModel';
const tempDirs: string[] = [];
const makeTempCodexHome = async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'lobe-codex-model-'));
tempDirs.push(dir);
return dir;
};
describe('codex model metadata helpers', () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
it('parses explicit model and profile flags from Codex args', () => {
expect(parseCodexModelFromArgs(['exec', '--model', 'gpt-5.5'])).toBe('gpt-5.5');
expect(parseCodexModelFromArgs(['exec', '-m=gpt-5.4'])).toBe('gpt-5.4');
expect(parseCodexModelFromArgs(['exec', '-c', 'model="gpt-5.3"'])).toBe('gpt-5.3');
expect(parseCodexModelFromArgs(['exec', '--config=model="gpt-5.2"'])).toBe('gpt-5.2');
expect(parseCodexProfileFromArgs(['exec', '--profile', 'fast'])).toBe('fast');
expect(parseCodexProfileFromArgs(['exec', '-p=deep'])).toBe('deep');
});
it('resolves the initial model from CODEX_HOME config profile fallback', async () => {
const codexHome = await makeTempCodexHome();
await writeFile(
path.join(codexHome, 'config.toml'),
[
'model = "gpt-5.4"',
'',
'[profiles.fast]',
'model = "gpt-5.5-mini"',
'',
'[profiles."quoted.name"]',
'model = "gpt-5.5"',
].join('\n'),
);
await expect(
resolveCodexInitialModel({
args: ['exec', '--profile', 'fast'],
env: { CODEX_HOME: codexHome },
}),
).resolves.toEqual({
model: 'gpt-5.5-mini',
profile: 'fast',
source: 'config',
});
await expect(
resolveCodexInitialModel({
args: ['exec'],
env: { CODEX_HOME: codexHome },
}),
).resolves.toEqual({
model: 'gpt-5.4',
profile: undefined,
source: 'config',
});
});
it('prefers explicit args over config defaults', async () => {
const codexHome = await makeTempCodexHome();
await writeFile(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
await expect(
resolveCodexInitialModel({
args: ['exec', '--model', 'gpt-5.5'],
env: { CODEX_HOME: codexHome },
}),
).resolves.toEqual({
model: 'gpt-5.5',
profile: undefined,
source: 'args',
});
});
it('reads model metadata from the matching Codex rollout session file', async () => {
const codexHome = await makeTempCodexHome();
const sessionDir = path.join(codexHome, 'sessions', '2026', '06', '11');
await mkdir(sessionDir, { recursive: true });
await writeFile(
path.join(sessionDir, 'rollout-2026-06-11T01-31-27-thread-123.jsonl'),
[
JSON.stringify({ payload: { model_provider: 'openai' }, type: 'session_meta' }),
JSON.stringify({ payload: { model_context_window: 258_400 }, type: 'turn_context' }),
JSON.stringify({ payload: { model: 'gpt-5.5' }, type: 'event_msg' }),
].join('\n'),
);
await expect(
readCodexSessionModel('thread-123', { env: { CODEX_HOME: codexHome } }),
).resolves.toMatchObject({
contextWindow: 258_400,
line: 3,
model: 'gpt-5.5',
provider: 'openai',
});
});
});
@@ -0,0 +1,299 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
type CodexEnv = Record<string, string | undefined>;
export type CodexInitialModelSource = 'args' | 'config';
export interface CodexInitialModelResolution {
model: string;
profile?: string;
source: CodexInitialModelSource;
}
export interface CodexSessionModelInfo {
contextWindow?: number;
line?: number;
model?: string;
provider?: string;
sourceFile?: string;
}
interface CodexModelResolveOptions {
args: string[];
env?: CodexEnv;
homeDir?: string;
}
interface CodexSessionModelReadOptions {
env?: CodexEnv;
homeDir?: string;
}
const CODEX_CONFIG_OVERRIDES = ['-c', '--config'] as const;
const CODEX_MODEL_FLAGS = ['-m', '--model'] as const;
const CODEX_PROFILE_FLAGS = ['-p', '--profile'] as const;
const unquoteTomlString = (value: string): string => {
const trimmed = value.trim();
const quote = trimmed[0];
if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) {
return trimmed.slice(1, -1);
}
return trimmed;
};
const parseTomlStringAssignment = (line: string, key: string): string | undefined => {
const match = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*(?:#.*)?$`));
if (!match?.[1]) return;
const value = unquoteTomlString(match[1]);
return value ? value : undefined;
};
const normalizeProfileName = (raw: string): string => unquoteTomlString(raw.trim());
const parseTomlTableName = (line: string): string | undefined => {
const match = line.trim().match(/^\[([^\]]+)\]/);
return match?.[1]?.trim();
};
const getProfileNameFromTable = (table: string): string | undefined => {
if (!table.startsWith('profiles.')) return;
const raw = table.slice('profiles.'.length).trim();
return raw ? normalizeProfileName(raw) : undefined;
};
export const getCodexHome = (
env: CodexEnv = process.env,
homeDir: string = os.homedir(),
): string => {
const configured = env.CODEX_HOME?.trim();
return configured ? configured : path.join(homeDir, '.codex');
};
export const parseCodexModelFromArgs = (args: string[]): string | undefined => {
let model: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (CODEX_MODEL_FLAGS.includes(arg as (typeof CODEX_MODEL_FLAGS)[number])) {
const next = args[index + 1];
if (next && !next.startsWith('-')) {
model = next;
index += 1;
}
continue;
}
const modelFlag = CODEX_MODEL_FLAGS.find((flag) => arg.startsWith(`${flag}=`));
if (modelFlag) {
const value = arg.slice(modelFlag.length + 1).trim();
if (value) model = value;
continue;
}
if (CODEX_CONFIG_OVERRIDES.includes(arg as (typeof CODEX_CONFIG_OVERRIDES)[number])) {
const next = args[index + 1];
if (next) {
const value = parseTomlStringAssignment(next, 'model');
if (value) model = value;
index += 1;
}
continue;
}
const configFlag = CODEX_CONFIG_OVERRIDES.find((flag) => arg.startsWith(`${flag}=`));
if (configFlag) {
const value = parseTomlStringAssignment(arg.slice(configFlag.length + 1), 'model');
if (value) model = value;
}
}
return model;
};
export const parseCodexProfileFromArgs = (args: string[]): string | undefined => {
let profile: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (CODEX_PROFILE_FLAGS.includes(arg as (typeof CODEX_PROFILE_FLAGS)[number])) {
const next = args[index + 1];
if (next && !next.startsWith('-')) {
profile = next;
index += 1;
}
continue;
}
const profileFlag = CODEX_PROFILE_FLAGS.find((flag) => arg.startsWith(`${flag}=`));
if (profileFlag) {
const value = arg.slice(profileFlag.length + 1).trim();
if (value) profile = value;
}
}
return profile;
};
const parseCodexConfigModels = (
content: string,
): { defaultModel?: string; profileModels: Map<string, string> } => {
let currentProfile: string | undefined;
let inNonProfileTable = false;
const profileModels = new Map<string, string>();
let defaultModel: string | undefined;
for (const line of content.split(/\r?\n/)) {
const table = parseTomlTableName(line);
if (table) {
currentProfile = getProfileNameFromTable(table);
inNonProfileTable = !currentProfile;
continue;
}
const model = parseTomlStringAssignment(line, 'model');
if (!model) continue;
if (currentProfile) {
profileModels.set(currentProfile, model);
} else if (!inNonProfileTable) {
defaultModel = model;
}
}
return { defaultModel, profileModels };
};
export const resolveCodexInitialModel = async ({
args,
env = process.env,
homeDir,
}: CodexModelResolveOptions): Promise<CodexInitialModelResolution | undefined> => {
const modelFromArgs = parseCodexModelFromArgs(args);
const profile = parseCodexProfileFromArgs(args);
if (modelFromArgs) return { model: modelFromArgs, profile, source: 'args' };
try {
const config = await readFile(path.join(getCodexHome(env, homeDir), 'config.toml'), 'utf8');
const { defaultModel, profileModels } = parseCodexConfigModels(config);
const profileModel = profile ? profileModels.get(profile) : undefined;
const model = profileModel || defaultModel;
return model ? { model, profile, source: 'config' } : undefined;
} catch {
return;
}
};
const findCodexSessionFiles = async (root: string, threadId: string): Promise<string[]> => {
const out: string[] = [];
const visit = async (dir: string): Promise<void> => {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await visit(fullPath);
return;
}
if (entry.isFile() && entry.name.includes(threadId) && entry.name.endsWith('.jsonl')) {
out.push(fullPath);
}
}),
);
};
await visit(root);
return out;
};
const readNewestMatchingSessionFile = async (
codexHome: string,
threadId: string,
): Promise<string | undefined> => {
const roots = [path.join(codexHome, 'sessions'), path.join(codexHome, 'archived_sessions')];
const files = (await Promise.all(roots.map((root) => findCodexSessionFiles(root, threadId))))
.flat()
.filter(Boolean);
if (files.length === 0) return;
const stats = await Promise.all(
files.map(async (file) => ({
file,
mtimeMs: await stat(file)
.then((item) => item.mtimeMs)
.catch(() => 0),
})),
);
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
return stats[0]?.file;
};
const getNumberValue = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const getStringValue = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value : undefined;
export const readCodexSessionModel = async (
threadId: string | undefined,
{ env = process.env, homeDir }: CodexSessionModelReadOptions = {},
): Promise<CodexSessionModelInfo | undefined> => {
if (!threadId) return;
const sourceFile = await readNewestMatchingSessionFile(getCodexHome(env, homeDir), threadId);
if (!sourceFile) return;
let model: string | undefined;
let provider: string | undefined;
let contextWindow: number | undefined;
let lineNumber: number | undefined;
const content = await readFile(sourceFile, 'utf8').catch(() => undefined);
if (!content) return;
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim();
if (!line) continue;
try {
const record = JSON.parse(line);
const payload = record?.payload;
const payloadModel =
getStringValue(payload?.model) ||
getStringValue(payload?.collaboration_mode?.settings?.model);
if (payloadModel) {
model = payloadModel;
lineNumber = index + 1;
}
provider = getStringValue(payload?.model_provider) || provider;
contextWindow = getNumberValue(payload?.model_context_window) || contextWindow;
} catch {
continue;
}
}
return model || provider || contextWindow
? { contextWindow, line: lineNumber, model, provider, sourceFile }
: undefined;
};
@@ -15,6 +15,16 @@
export { AgentStreamPipeline, type AgentStreamPipelineOptions } from './agentStreamPipeline';
export { type CliSpawnPlan, resolveCliSpawnPlan } from './cliSpawn';
export { CodexFileChangeTracker } from './codexFileChangeTracker';
export {
type CodexInitialModelResolution,
type CodexInitialModelSource,
type CodexSessionModelInfo,
getCodexHome,
parseCodexModelFromArgs,
parseCodexProfileFromArgs,
readCodexSessionModel,
resolveCodexInitialModel,
} from './codexModel';
export {
type AgentContentBlock,
type AgentImageBlock,
@@ -5,6 +5,7 @@ import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
import { AgentStreamPipeline } from './agentStreamPipeline';
import { resolveCliSpawnPlan } from './cliSpawn';
import { resolveCodexInitialModel } from './codexModel';
import type { AgentPromptInput, BuildAgentInputOptions } from './input';
import { buildAgentInput } from './input';
@@ -256,12 +257,17 @@ export const spawnAgent = async (options: SpawnAgentOptions): Promise<SpawnAgent
resumeSessionId: options.resumeSessionId,
});
const cwd = options.cwd || process.cwd();
const childEnv = { ...process.env, ...options.env };
const initialModel =
options.agentType === 'codex'
? (await resolveCodexInitialModel({ args, env: childEnv }))?.model
: undefined;
const cliSpawnPlan = await resolveCliSpawnPlan(command, args);
const proc = spawn(cliSpawnPlan.command, cliSpawnPlan.args, {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...options.env },
env: childEnv,
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -280,6 +286,8 @@ export const spawnAgent = async (options: SpawnAgentOptions): Promise<SpawnAgent
const pipeline = new AgentStreamPipeline({
agentType: options.agentType,
cwd,
initialModel,
operationId: options.operationId,
});
const stdout = proc.stdout!;
@@ -2,6 +2,25 @@
import { defineFixtures, single } from './_helpers';
const addedFileDiff = `diff --git a/src/routes/(main)/devtools/index.tsx b/src/routes/(main)/devtools/index.tsx
--- /dev/null
+++ b/src/routes/(main)/devtools/index.tsx
@@ -0,0 +1,4 @@
+import DevtoolsPanel from '@/features/DevPanel';
+
+export default DevtoolsPanel;
`;
const modifiedRegistryDiff = `diff --git a/packages/builtin-tools/src/renders.ts b/packages/builtin-tools/src/renders.ts
--- a/packages/builtin-tools/src/renders.ts
+++ b/packages/builtin-tools/src/renders.ts
@@ -12,6 +12,7 @@
export const builtinRenders = {
codex: CodexRenders,
+ devtools: DevtoolsRenders,
};
`;
export default defineFixtures({
identifier: 'codex',
meta: {
@@ -46,12 +65,14 @@ export default defineFixtures({
args: {
changes: [
{
diffText: addedFileDiff,
kind: 'add',
linesAdded: 62,
linesDeleted: 0,
path: 'src/routes/(main)/devtools/index.tsx',
},
{
diffText: modifiedRegistryDiff,
kind: 'modify',
linesAdded: 23,
linesDeleted: 0,
@@ -87,6 +108,7 @@ export default defineFixtures({
path: 'tmp/devtools-preview-old.tsx',
},
],
diffText: `${addedFileDiff}\n${modifiedRegistryDiff}`,
linesAdded: 113,
linesDeleted: 0,
},
@@ -338,6 +338,11 @@ const codexThreadStarted = (threadId = 'codex-thread-1') => ({
type: 'thread.started',
});
const codexSessionConfigured = (model = 'gpt-5.5') => ({
model,
type: 'session_configured',
});
const codexTurnStarted = () => ({
type: 'turn.started',
});
@@ -1279,6 +1284,44 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
});
describe('Codex multi-turn persistence', () => {
it('should persist Codex host model metadata onto the current assistant message', async () => {
await runWithEvents(
[
codexSessionConfigured('gpt-5.5'),
codexThreadStarted(),
codexTurnStarted(),
codexAgentMessage('item_0', 'Done.'),
codexTurnCompleted({ cached_input_tokens: 4, input_tokens: 6, output_tokens: 3 }),
],
{
params: {
heterogeneousProvider: { command: 'codex', type: 'codex' as const },
},
},
);
const modelWrites = mockUpdateMessage.mock.calls.filter(
([id, value]: any) =>
id === 'ast-initial' && value.model === 'gpt-5.5' && value.provider === 'codex',
);
expect(modelWrites.length).toBeGreaterThan(0);
const usageWrite = modelWrites.find(([, value]: any) => value.metadata?.usage);
expect(usageWrite?.[1]).toMatchObject({
metadata: {
usage: {
inputCachedTokens: 4,
inputCacheMissTokens: 6,
totalInputTokens: 10,
totalOutputTokens: 3,
totalTokens: 13,
},
},
model: 'gpt-5.5',
provider: 'codex',
});
});
it('should switch to a new assistant before persisting the next turn tool', async () => {
const idCounter = { assistant: 0, tool: 0 };
mockCreateMessage.mockImplementation(async (params: any) => {
@@ -1229,14 +1229,18 @@ export const executeHeterogeneousAgent = async (
if (event.data.model) lastModel = event.data.model;
if (event.data.provider) lastProvider = event.data.provider;
if (turnUsage) {
const updateValue: Record<string, any> = {};
if (turnUsage) updateValue.metadata = { usage: turnUsage };
if (event.data.model) updateValue.model = event.data.model;
if (event.data.provider) updateValue.provider = event.data.provider;
if (Object.keys(updateValue).length > 0) {
persistQueue = persistQueue.then(async () => {
await messageService
.updateMessage(
currentAssistantMessageId,
{ metadata: { usage: turnUsage } },
{ agentId: context.agentId, topicId: context.topicId },
)
.updateMessage(currentAssistantMessageId, updateValue, {
agentId: context.agentId,
topicId: context.topicId,
})
.catch(console.error);
});
}