mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
fix codex thread continuity
This commit is contained in:
committed by
Peter Steinberger
parent
5de7f99801
commit
8cf1800ee9
@@ -326,6 +326,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168.
|
||||
- Plugins/config: report configured plugins that are present but blocked by path-safety checks as blocked instead of stale `plugin not found` entries, and deduplicate repeated blocked-candidate warnings during discovery. Fixes #76144. Thanks @mayank6136.
|
||||
- Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay.
|
||||
- Codex/runtime: preserve native Codex thread bindings across dynamic-tool reorder and no-tool maintenance turns, and project mirrored history when a legacy Codex run must start without a native binding, preventing follow-up requests from losing conversation context. Thanks @VACInc.
|
||||
- CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable <plugin>` repair command. (#76835)
|
||||
- Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209.
|
||||
- Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1.
|
||||
|
||||
@@ -146,6 +146,14 @@ function assistantMessage(text: string, timestamp: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(text: string, timestamp: number) {
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createAppServerHarness(
|
||||
requestImpl: (method: string, params: unknown) => Promise<unknown>,
|
||||
options: {
|
||||
@@ -752,6 +760,34 @@ describe("runCodexAppServerAttempt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("projects mirrored history when starting Codex without a native thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
|
||||
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.prompt = "make the default webpage openclaw";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const inputText =
|
||||
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
|
||||
"";
|
||||
|
||||
expect(inputText).toContain("OpenClaw assembled context for this turn:");
|
||||
expect(inputText).toContain("we are fixing the Opik default project");
|
||||
expect(inputText).toContain("Opik default project context");
|
||||
expect(inputText).toContain("Current user request:");
|
||||
expect(inputText).toContain("make the default webpage openclaw");
|
||||
});
|
||||
|
||||
it("passes OpenClaw bootstrap files through Codex config instructions", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -2048,6 +2084,90 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
});
|
||||
|
||||
it("resumes a bound Codex thread when dynamic tools are reordered", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createNamedDynamicTool("wiki_status"), createNamedDynamicTool("diffs")],
|
||||
appServer,
|
||||
});
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createNamedDynamicTool("diffs"), createNamedDynamicTool("wiki_status")],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-existing");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
});
|
||||
|
||||
it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let nextThread = 1;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult(`thread-${nextThread++}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
|
||||
appServer,
|
||||
});
|
||||
const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint;
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
});
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
|
||||
appServer,
|
||||
});
|
||||
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
dynamicToolsFingerprint: fingerprint,
|
||||
threadId: "thread-1",
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
"thread/start",
|
||||
"thread/resume",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the binding when the app-server closes during thread resume", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -88,8 +88,10 @@ import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./s
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
|
||||
import {
|
||||
areCodexDynamicToolFingerprintsCompatible,
|
||||
buildDeveloperInstructions,
|
||||
buildTurnStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
startOrResumeThread,
|
||||
} from "./thread-lifecycle.js";
|
||||
import {
|
||||
@@ -500,6 +502,20 @@ export async function runCodexAppServerAttempt(
|
||||
error: formatErrorMessage(assembleErr),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
shouldProjectMirroredHistoryForCodexStart({
|
||||
startupBinding,
|
||||
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
|
||||
historyMessages,
|
||||
})
|
||||
) {
|
||||
const projection = projectContextEngineAssemblyForCodex({
|
||||
assembledMessages: historyMessages,
|
||||
originalHistoryMessages: historyMessages,
|
||||
prompt: params.prompt,
|
||||
});
|
||||
promptText = projection.promptText;
|
||||
prePromptMessageCount = projection.prePromptMessageCount;
|
||||
}
|
||||
const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({
|
||||
prompt: promptText,
|
||||
@@ -1546,6 +1562,23 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
});
|
||||
}
|
||||
|
||||
function shouldProjectMirroredHistoryForCodexStart(params: {
|
||||
startupBinding: CodexAppServerThreadBinding | undefined;
|
||||
dynamicToolsFingerprint: string;
|
||||
historyMessages: AgentMessage[];
|
||||
}): boolean {
|
||||
if (!params.historyMessages.some((message) => message.role === "user")) {
|
||||
return false;
|
||||
}
|
||||
if (!params.startupBinding?.threadId) {
|
||||
return true;
|
||||
}
|
||||
return !areCodexDynamicToolFingerprintsCompatible({
|
||||
previous: params.startupBinding.dynamicToolsFingerprint,
|
||||
next: params.dynamicToolsFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
async function withCodexStartupTimeout<T>(params: {
|
||||
timeoutMs: number;
|
||||
timeoutFloorMs?: number;
|
||||
|
||||
@@ -47,20 +47,37 @@ export async function startOrResumeThread(params: {
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
});
|
||||
let preserveExistingBinding = false;
|
||||
if (binding?.threadId) {
|
||||
// `/codex resume <thread>` writes a binding before the next turn can know
|
||||
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
|
||||
if (
|
||||
binding.dynamicToolsFingerprint &&
|
||||
binding.dynamicToolsFingerprint !== dynamicToolsFingerprint
|
||||
!areDynamicToolFingerprintsCompatible(
|
||||
binding.dynamicToolsFingerprint,
|
||||
dynamicToolsFingerprint,
|
||||
)
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server dynamic tool catalog changed; starting a new thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
preserveExistingBinding = shouldStartTransientNoToolThread({
|
||||
previous: binding.dynamicToolsFingerprint,
|
||||
next: dynamicToolsFingerprint,
|
||||
});
|
||||
if (preserveExistingBinding) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server dynamic tools unavailable for turn; starting transient thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server dynamic tool catalog changed; starting a new thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
|
||||
@@ -142,23 +159,25 @@ export async function startOrResumeThread(params: {
|
||||
config: params.params.config,
|
||||
});
|
||||
const createdAt = new Date().toISOString();
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: params.params.authProfileId,
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
},
|
||||
);
|
||||
if (!preserveExistingBinding) {
|
||||
await writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
threadId: response.thread.id,
|
||||
cwd: params.cwd,
|
||||
authProfileId: params.params.authProfileId,
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
createdAt,
|
||||
},
|
||||
{
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
config: params.params.config,
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
threadId: response.thread.id,
|
||||
@@ -284,8 +303,21 @@ function buildHeartbeatCollaborationInstructions(): string {
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function codexDynamicToolsFingerprint(dynamicTools: CodexDynamicToolSpec[]): string {
|
||||
return fingerprintDynamicTools(dynamicTools);
|
||||
}
|
||||
|
||||
export function areCodexDynamicToolFingerprintsCompatible(params: {
|
||||
previous?: string;
|
||||
next: string;
|
||||
}): boolean {
|
||||
return areDynamicToolFingerprintsCompatible(params.previous, params.next);
|
||||
}
|
||||
|
||||
function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
|
||||
return JSON.stringify(dynamicTools.map(fingerprintDynamicToolSpec));
|
||||
return JSON.stringify(
|
||||
dynamicTools.map(fingerprintDynamicToolSpec).toSorted(compareJsonFingerprint),
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
|
||||
@@ -320,6 +352,27 @@ function stabilizeJsonValue(value: JsonValue): JsonValue {
|
||||
return stable;
|
||||
}
|
||||
|
||||
const EMPTY_DYNAMIC_TOOLS_FINGERPRINT = JSON.stringify([]);
|
||||
|
||||
function areDynamicToolFingerprintsCompatible(previous: string | undefined, next: string): boolean {
|
||||
return !previous || previous === next;
|
||||
}
|
||||
|
||||
function shouldStartTransientNoToolThread(params: {
|
||||
previous: string | undefined;
|
||||
next: string;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
params.previous &&
|
||||
params.previous !== EMPTY_DYNAMIC_TOOLS_FINGERPRINT &&
|
||||
params.next === EMPTY_DYNAMIC_TOOLS_FINGERPRINT,
|
||||
);
|
||||
}
|
||||
|
||||
function compareJsonFingerprint(left: JsonValue, right: JsonValue): number {
|
||||
return JSON.stringify(left).localeCompare(JSON.stringify(right));
|
||||
}
|
||||
|
||||
export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string {
|
||||
const promptOverlay = renderCodexRuntimePromptOverlay(params);
|
||||
const sections = [
|
||||
|
||||
Reference in New Issue
Block a user