mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user