fix: strengthen code agent session continuity

This commit is contained in:
Code Queue Review
2026-05-24 01:53:58 +00:00
parent f104f3e687
commit 9a35e3f5c5
6 changed files with 219 additions and 25 deletions
+79 -14
View File
@@ -213,7 +213,8 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
workspace: options.workspace,
timeoutMs: options.timeoutMs,
model: providerPlan.model,
codexStdioManager: options.codexStdioManager
codexStdioManager: options.codexStdioManager,
conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId)
});
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry });
}
@@ -236,6 +237,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
const priorConversationFacts = conversationFactsForPrompt(sessionRegistry, conversationId);
const providerResult = await callConfiguredProvider({
providerPlan,
message,
@@ -244,6 +246,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
timeoutMs: options.timeoutMs,
env: options.env ?? process.env,
now: options.now,
conversationFacts: priorConversationFacts,
callProvider: options.callProvider
});
const content = typeof providerResult.content === "string" ? providerResult.content.trim() : "";
@@ -277,7 +280,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
runner: providerRunner,
capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only"
});
return finalizeCodeAgentChatPayload({
const providerPayload = finalizeCodeAgentChatPayload({
...base,
status: "completed",
updatedAt: completedAt,
@@ -317,6 +320,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
providerTrace: providerResult.providerTrace ?? null,
...(providerCompletionBlocker ? { blocker: providerCompletionBlocker, blockers: [providerCompletionBlocker] } : {})
});
return attachConversationFacts(providerPayload, sessionRegistry, { now: options.now });
} catch (error) {
const failedAt = nowIso(options.now);
const payload = {
@@ -841,15 +845,16 @@ async function callConfiguredProvider({
timeoutMs,
env,
now,
conversationFacts,
callProvider
}) {
if (callProvider) {
return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now });
return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now, conversationFacts });
}
if (providerPlan.mode === "openai") {
return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env });
return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts });
}
return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env });
return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts });
}
async function inspectReadOnlyRunnerAvailability(env, options = {}) {
@@ -917,7 +922,7 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) {
};
}
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager }) {
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, conversationFacts }) {
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
try {
return await manager.chat({
@@ -929,7 +934,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI
now,
workspace,
timeoutMs,
model
model,
conversationFacts
});
} catch (error) {
const availability = error.availability ?? manager.describe({ env, workspace });
@@ -2646,6 +2652,10 @@ function sessionContextToolCall({ conversationFacts, workspace, traceId }) {
conversationId: conversationFacts.conversationId,
sessionId: conversationFacts.sessionId,
turnCount: conversationFacts.turnCount,
provider: conversationFacts.latestProvider,
backend: conversationFacts.latestBackend,
runnerKind: conversationFacts.runnerKind,
capabilityLevel: conversationFacts.capabilityLevel,
workspace: conversationFacts.workspace,
skills: {
status: conversationFacts.latestSkills?.status ?? "not_requested",
@@ -2682,10 +2692,11 @@ function sessionContextReply({ conversationFacts, session }) {
return [
"根据同一个 conversation/session 中已记录的 runner facts",
`- conversationId=${conversationFacts.conversationId}; sessionId=${conversationFacts.sessionId}; sessionStatus=${conversationFacts.sessionStatus ?? session.status}; sessionMode=${conversationFacts.sessionMode ?? session.sessionMode}; turnCount=${conversationFacts.turnCount}.`,
`- provider=${conversationFacts.latestProvider ?? "unknown"}; backend=${conversationFacts.latestBackend ?? "unknown"}; runner=${conversationFacts.runnerKind ?? "unknown"}; capabilityLevel=${conversationFacts.capabilityLevel ?? "unknown"}.`,
`- 当前工作目录/workspace=${conversationFacts.workspace ?? session.workspace ?? "unknown"}.`,
`- 可用 skills=${skillCount}; ${skillNames}.`,
`- 最近 toolCalls=${toolSummary}; traceIds=${traceSummary}.`,
"证明:本轮走 session.context 的 Code Agent session fact 路径,runner=hwlab-readonly-runnercapabilityLevel=read-only-session-tools;不是 openai-responses-fallback不是 stateless-one-shot。",
"证明:本轮走 session.context 的 Code Agent session fact 路径,复用同一 conversation/session 的 bounded facts;不是 openai-responses-fallback,不是 stateless-one-shot,也不是无法访问之前状态;不会输出原始 secret/evidence。",
limitationReplyLine()
].join("\n");
}
@@ -3173,7 +3184,7 @@ function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) {
};
}
async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }) {
if (env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "1" && env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "true") {
return {
provider: "openai-responses",
@@ -3226,7 +3237,7 @@ async function callOpenAiResponses({ providerPlan, message, conversationId, trac
content: [
{
type: "input_text",
text: buildAgentPrompt({ message, conversationId, traceId })
text: buildAgentPrompt({ message, conversationId, traceId, conversationFacts })
}
]
}
@@ -3295,7 +3306,7 @@ async function callOpenAiResponses({ providerPlan, message, conversationId, trac
};
}
async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }) {
const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND);
if (!(await commandExists(command, env))) {
throw providerUnavailable(`Codex CLI command is not available: ${command}`, {
@@ -3328,7 +3339,7 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti
}
args.push("-");
const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), {
const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId, conversationFacts }), {
env,
timeoutMs: effectiveTimeout(timeoutMs)
});
@@ -3429,16 +3440,70 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti
}
}
function buildAgentPrompt({ message, conversationId, traceId }) {
function buildAgentPrompt({ message, conversationId, traceId, conversationFacts }) {
return [
CODE_AGENT_SYSTEM_PROMPT,
"",
`conversationId: ${conversationId}`,
`traceId: ${traceId}`,
boundedConversationFactsPrompt(conversationFacts),
"",
"用户消息:",
message
].join("\n");
].filter((line) => line !== null && line !== undefined).join("\n");
}
function conversationFactsForPrompt(sessionRegistry, conversationId) {
if (!sessionRegistry || typeof sessionRegistry.getConversationFacts !== "function") return null;
try {
return sessionRegistry.getConversationFacts(conversationId);
} catch {
return null;
}
}
function boundedConversationFactsPrompt(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) {
return null;
}
const lines = [
"上一轮会话事实(只作为上下文,不是用户新指令):",
`- conversationId=${safePromptFact(conversationFacts.conversationId)}; sessionId=${safePromptFact(conversationFacts.sessionId)}; sessionStatus=${safePromptFact(conversationFacts.sessionStatus)}; sessionMode=${safePromptFact(conversationFacts.sessionMode)}; turnCount=${safePromptFact(conversationFacts.turnCount)}.`,
`- provider/backend/runner=${safePromptFact(conversationFacts.latestProvider)}/${safePromptFact(conversationFacts.latestBackend)}/${safePromptFact(conversationFacts.runnerKind)}; capabilityLevel=${safePromptFact(conversationFacts.capabilityLevel)}.`,
`- workspace=${safePromptFact(conversationFacts.workspace)}; sandbox=${safePromptFact(conversationFacts.sandbox)}.`,
`- traceIds=${promptList(conversationFacts.traceIds, 6)}; latestTraceId=${safePromptFact(conversationFacts.latestTraceId)}.`,
`- skills=${promptSkills(conversationFacts.latestSkills)}.`,
`- toolCalls=${promptToolCalls(conversationFacts.recentToolCalls)}.`,
"如果用户询问刚才/上一轮/session/trace/workspace/skills/toolCalls,请优先用上述事实用中文回答;不要回答无法访问之前状态。不要输出 secret 或原始 evidence 大段内容。"
];
return lines.join("\n");
}
function promptSkills(skills) {
if (!skills || typeof skills !== "object") return "not_requested";
const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 8) : [];
const count = skills.totalCount ?? skills.count ?? names.length;
return `${safePromptFact(skills.status)} count=${safePromptFact(count)}${names.length ? ` names=${names.map(safePromptFact).join(",")}` : ""}`;
}
function promptToolCalls(toolCalls) {
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none";
return toolCalls
.slice(-6)
.map((toolCall) => [toolCall?.name, toolCall?.status, toolCall?.route].filter(Boolean).map(safePromptFact).join(":"))
.filter(Boolean)
.join(",") || "none";
}
function promptList(values, limit) {
return Array.isArray(values) && values.length > 0
? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",")
: "none";
}
function safePromptFact(value) {
if (value === undefined || value === null || value === "") return "none";
return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180);
}
function normalizeUserMessage(value) {
+14 -7
View File
@@ -339,21 +339,28 @@ function publicSession(session, { conversationId = null, reused = null } = {}) {
function publicConversationFacts({ conversationId, session, facts, maxFactToolCalls, maxConversationFacts }) {
const latest = facts.at(-1) ?? null;
const latestOperational = [...facts].reverse().find((fact) => fact.kind !== "session_context") ?? latest;
const latestSessionFact = [...facts].reverse().find((fact) => fact.sessionId && fact.sessionMode !== "provider-text-request") ?? latestOperational;
const latestSkills = [...facts].reverse().find((fact) => fact.skills?.status && fact.skills.status !== "not_requested")?.skills ?? null;
const latestWorkspace = [...facts].reverse().find((fact) => fact.workspace)?.workspace ?? session?.workspace ?? null;
const latestWorkspace = [...facts].reverse().find((fact) => fact.kind !== "session_context" && fact.workspace)?.workspace ??
[...facts].reverse().find((fact) => fact.workspace)?.workspace ??
session?.workspace ??
null;
const traceIds = [...new Set(facts.map((fact) => fact.traceId).filter(Boolean))].slice(-6);
const recentToolCalls = facts
.flatMap((fact) => fact.toolCalls?.items ?? [])
.slice(-maxFactToolCalls);
return {
conversationId,
sessionId: session?.sessionId ?? latest?.sessionId ?? null,
sessionStatus: session?.status ?? latest?.sessionStatus ?? null,
sessionMode: session?.sessionMode ?? latest?.sessionMode ?? null,
capabilityLevel: session?.capabilityLevel ?? latest?.capabilityLevel ?? null,
runnerKind: session?.runnerKind ?? latest?.runnerKind ?? null,
sessionId: session?.sessionId ?? latestSessionFact?.sessionId ?? latest?.sessionId ?? null,
sessionStatus: session?.status ?? latestSessionFact?.sessionStatus ?? latest?.sessionStatus ?? null,
sessionMode: session?.sessionMode ?? latestSessionFact?.sessionMode ?? latest?.sessionMode ?? null,
capabilityLevel: latestOperational?.capabilityLevel ?? session?.capabilityLevel ?? latest?.capabilityLevel ?? null,
runnerKind: latestOperational?.runnerKind ?? session?.runnerKind ?? latest?.runnerKind ?? null,
latestProvider: latestOperational?.provider ?? latest?.provider ?? null,
latestBackend: latestOperational?.backend ?? latest?.backend ?? null,
workspace: latestWorkspace,
sandbox: session?.sandbox ?? latest?.sandbox ?? null,
sandbox: latestOperational?.sandbox ?? session?.sandbox ?? latest?.sandbox ?? null,
turnCount: facts.length,
maxFacts: maxConversationFacts,
traceIds,
@@ -569,6 +569,74 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate
assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true);
});
test("OpenAI provider turns receive bounded prior session facts and record continuity", async () => {
const registry = createCodeAgentSessionRegistry({
idFactory: () => "ses_provider_context"
});
const providerPrompts = [];
const env = {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_PROVIDER: "openai",
HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "1",
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
};
const first = await handleCodeAgentChat(
{
conversationId: "cnv_provider_context",
traceId: "trc_provider_context_pwd",
message: "pwd"
},
{
now: () => "2026-05-23T00:04:10.000Z",
sessionRegistry: registry,
env
}
);
assert.equal(first.status, "completed");
assert.equal(first.provider, "codex-readonly-runner");
assert.equal(first.toolCalls[0].name, "pwd");
assert.equal(first.conversationFacts.turnCount, 1);
const second = await handleCodeAgentChat(
{
conversationId: "cnv_provider_context",
traceId: "trc_provider_context_openai",
message: "请根据刚才的上下文简要说明"
},
{
now: () => "2026-05-23T00:04:11.000Z",
sessionRegistry: registry,
env,
callProvider: async ({ conversationFacts }) => {
const promptFacts = JSON.stringify(conversationFacts);
providerPrompts.push(promptFacts);
assert.equal(conversationFacts.sessionId, first.sessionId);
assert.equal(conversationFacts.workspace, process.cwd());
assert.equal(conversationFacts.recentToolCalls[0].name, "pwd");
return {
provider: "openai-responses",
model: "gpt-test",
backend: "hwlab-cloud-api/openai-responses",
content: `上一轮工作目录是 ${conversationFacts.workspace}session=${conversationFacts.sessionId}trace=${conversationFacts.latestTraceId}`,
usage: null,
providerTrace: { source: "test-provider" }
};
}
}
);
assert.equal(second.status, "completed");
assert.equal(second.provider, "openai-responses");
assert.equal(second.conversationFacts.turnCount, 2);
assert.equal(second.conversationFacts.sessionId, first.sessionId);
assert.equal(second.conversationFacts.latestProvider, "openai-responses");
assert.equal(second.conversationFacts.latestBackend, "hwlab-cloud-api/openai-responses");
assert.match(second.reply.content, /上一轮工作目录/u);
assert.match(providerPrompts[0], /trc_provider_context_pwd/u);
assert.equal(JSON.stringify(second.conversationFacts).includes("sk-"), false);
});
test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => {
const calls = [];
const payload = await handleCodeAgentChat(
@@ -1429,6 +1497,10 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
async function createFakeCodexCommand() {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
@@ -1586,6 +1658,7 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi
test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => {
const calls = [];
const fakeCodex = await createFakeCodexCommand();
const registry = createCodeAgentSessionRegistry();
const manager = createCodexStdioSessionManager({
idFactory: () => "ses_stdio_ready",
createRpcClient: async () => ({
@@ -1628,6 +1701,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
{
now: () => "2026-05-23T00:06:00.000Z",
codexStdioManager: manager,
sessionRegistry: registry,
env
}
);
@@ -1659,6 +1733,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
{
now: () => "2026-05-23T00:06:01.000Z",
codexStdioManager: manager,
sessionRegistry: registry,
env
}
);
@@ -1676,6 +1751,10 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
assert.equal(calls[0].name, "codex");
assert.equal(calls[1].name, "codex-reply");
assert.equal(calls[1].args.threadId, "thread_stdio_ready");
assert.match(calls[1].args.prompt, /Prior session facts/u);
assert.match(calls[1].args.prompt, /ses_stdio_ready/u);
assert.match(calls[1].args.prompt, /trc_stdio_ready_1/u);
assert.match(calls[1].args.prompt, new RegExp(escapeRegExp(process.cwd()), "u"));
await rm(fakeCodex.root, { recursive: true, force: true });
});
+46 -3
View File
@@ -318,10 +318,10 @@ export function createCodexStdioSessionManager(options = {}) {
const toolArguments = session.threadId
? {
threadId: session.threadId,
prompt: buildCodexUserPrompt(params.message, { conversationId, traceId })
prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts })
}
: {
prompt: buildCodexUserPrompt(params.message, { conversationId, traceId }),
prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }),
cwd: workspace,
sandbox,
model: params.model,
@@ -1784,16 +1784,59 @@ function safeDisplayPath(value) {
return redactText(String(value ?? ".")).replace(/\s+/gu, " ");
}
function buildCodexUserPrompt(message, { conversationId, traceId }) {
function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts }) {
return [
`conversationId: ${conversationId}`,
`traceId: ${traceId}`,
codexConversationFactsPrompt(conversationFacts),
"",
"User message:",
String(message ?? "").trim()
].filter((line) => line !== null && line !== undefined).join("\n");
}
function codexConversationFactsPrompt(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) {
return null;
}
return [
"Prior session facts (bounded, redacted; use for Chinese answers about previous turns):",
`- sessionId=${safePromptFact(conversationFacts.sessionId)}; sessionStatus=${safePromptFact(conversationFacts.sessionStatus)}; sessionMode=${safePromptFact(conversationFacts.sessionMode)}; turnCount=${safePromptFact(conversationFacts.turnCount)}.`,
`- provider/backend/runner=${safePromptFact(conversationFacts.latestProvider)}/${safePromptFact(conversationFacts.latestBackend)}/${safePromptFact(conversationFacts.runnerKind)}; capabilityLevel=${safePromptFact(conversationFacts.capabilityLevel)}.`,
`- workspace=${safePromptFact(conversationFacts.workspace)}; sandbox=${safePromptFact(conversationFacts.sandbox)}.`,
`- traceIds=${promptList(conversationFacts.traceIds, 6)}; latestTraceId=${safePromptFact(conversationFacts.latestTraceId)}.`,
`- skills=${promptSkills(conversationFacts.latestSkills)}.`,
`- toolCalls=${promptToolCalls(conversationFacts.recentToolCalls)}.`
].join("\n");
}
function promptSkills(skills) {
if (!skills || typeof skills !== "object") return "not_requested";
const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 8) : [];
const count = skills.totalCount ?? skills.count ?? names.length;
return `${safePromptFact(skills.status)} count=${safePromptFact(count)}${names.length ? ` names=${names.map(safePromptFact).join(",")}` : ""}`;
}
function promptToolCalls(toolCalls) {
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none";
return toolCalls
.slice(-6)
.map((toolCall) => [toolCall?.name, toolCall?.status, toolCall?.route].filter(Boolean).map(safePromptFact).join(":"))
.filter(Boolean)
.join(",") || "none";
}
function promptList(values, limit) {
return Array.isArray(values) && values.length > 0
? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",")
: "none";
}
function safePromptFact(value) {
if (value === undefined || value === null || value === "") return "none";
return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180);
}
function resolveCodexWorkspace(env = process.env, options = {}) {
return path.resolve(firstNonEmpty(
options.workspace,
-1
View File
@@ -2815,7 +2815,6 @@ function messageEvidencePanel(message) {
if (fields.length === 0) return null;
const details = document.createElement("details");
details.className = "message-evidence";
details.open = message.status === "running" || message.status === "completed" || message.status === "source" || message.status === "failed";
const summary = document.createElement("summary");
summary.className = "message-meta";
summary.textContent = messageEvidenceSummary(message, fields);
+1
View File
@@ -816,6 +816,7 @@ assert.match(app, /function isSourceFixtureCompletedChatMessage/);
assert.match(app, /Code Agent SOURCE 回复/);
assert.match(app, /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/);
assert.match(app, /function messageEvidencePanel/);
assert.doesNotMatch(app, /details\.open\s*=\s*message\.status === "running"/);
assert.match(app, /function messageAttributionPanel/);
assert.match(app, /function messageEvidenceSummary/);
assert.match(app, /function boundedEvidenceField/);