feat: persist code agent session facts

This commit is contained in:
Code Queue Review
2026-05-24 00:25:28 +00:00
parent 48dfbf9e7f
commit 0d824eab41
9 changed files with 792 additions and 22 deletions
+159 -10
View File
@@ -125,6 +125,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
const messageId = `msg_${randomUUID()}`;
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
const base = {
conversationId,
sessionId,
@@ -156,10 +157,28 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
skillsDirs: options.skillsDirs,
skillsDirsExact: options.skillsDirsExact,
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
sessionRegistry: options.sessionRegistry,
sessionRegistry,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
if (runnerIntent.kind === "session_context") {
const runnerResult = await callReadOnlyRunner({
intent: runnerIntent,
conversationId,
traceId,
sessionId: requestedSessionId,
env: options.env ?? process.env,
now: options.now,
workspace: options.workspace,
skillsDirs: options.skillsDirs,
skillsDirsExact: options.skillsDirsExact,
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
sessionRegistry,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options);
@@ -194,7 +213,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
model: providerPlan.model,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now });
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry });
}
if (runnerIntent.kind === "m3_io") {
@@ -206,11 +225,11 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
env: options.env ?? process.env,
now: options.now,
workspace: options.workspace,
sessionRegistry: options.sessionRegistry,
sessionRegistry,
requestJson: options.m3IoSkillRequestJson,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
if (runnerIntent.kind !== "none") {
@@ -225,10 +244,10 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
skillsDirs: options.skillsDirs,
skillsDirsExact: options.skillsDirsExact,
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
sessionRegistry: options.sessionRegistry,
sessionRegistry,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
const providerResult = await callConfiguredProvider({
@@ -348,6 +367,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
if (error.conversationFacts !== undefined) {
payload.conversationFacts = error.conversationFacts;
} else if (typeof sessionRegistry.getConversationFacts === "function") {
payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId);
}
if (payload.conversationFacts && payload.error?.blocker) payload.error.blocker.conversationFacts = payload.conversationFacts;
if (payload.conversationFacts && payload.blocker) payload.blocker.conversationFacts = payload.conversationFacts;
if (error.availability !== undefined) {
payload.availability = error.availability;
} else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) {
@@ -359,7 +385,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
}
}
function completedRunnerPayload({ base, runnerResult, messageId, now }) {
function completedRunnerPayload({ base, runnerResult, messageId, now, sessionRegistry }) {
const completedAt = nowIso(now);
const blocker = structuredCompletionBlocker(runnerResult, {
traceId: base.traceId,
@@ -368,7 +394,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) {
runner: runnerResult.runner,
capabilityLevel: runnerResult.capabilityLevel
});
return finalizeCodeAgentChatPayload({
const payload = finalizeCodeAgentChatPayload({
...base,
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
status: "completed",
@@ -400,6 +426,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) {
usage: null,
providerTrace: runnerResult.providerTrace
});
return attachConversationFacts(payload, sessionRegistry, { now });
}
export function validateCodeAgentChatSchema(payload) {
@@ -492,6 +519,33 @@ function finalizeCodeAgentChatPayload(payload) {
return payload;
}
function attachConversationFacts(payload, sessionRegistry, { now } = {}) {
if (!sessionRegistry || typeof sessionRegistry.recordFact !== "function") return payload;
const conversationFacts = sessionRegistry.recordFact(payload.conversationId, {
kind: payload.toolCalls?.some((toolCall) => toolCall?.name === "session.context") ? "session_context" : "runner_turn",
conversationId: payload.conversationId,
sessionId: payload.sessionId,
traceId: payload.traceId,
provider: payload.provider,
backend: payload.backend,
workspace: payload.workspace,
sandbox: payload.sandbox,
session: payload.session,
sessionMode: payload.sessionMode,
sessionReuse: payload.sessionReuse,
implementationType: payload.implementationType,
runner: payload.runner,
runnerTrace: payload.runnerTrace,
capabilityLevel: payload.capabilityLevel,
toolCalls: payload.toolCalls,
skills: payload.skills
}, { now });
return {
...payload,
conversationFacts
};
}
export async function describeCodeAgentAvailability(env = process.env, options = {}) {
const providerPlan = resolveProviderPlan(env, options);
const providerContract = inspectCodeAgentProviderEnv(env);
@@ -970,6 +1024,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
});
if (!sessionAcquire.ok) {
const blockedSession = sessionAcquire.session;
const conversationFacts = registry.getConversationFacts(conversationId);
const blockedCodexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace });
const blockedTrace = runnerTrace({
traceId,
@@ -992,6 +1047,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility: blockedCodexStdioFeasibility,
conversationFacts,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
@@ -1261,6 +1317,39 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
});
}
if (intent.kind === "session_context") {
const conversationFacts = registry.getConversationFacts(conversationId);
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
const contextToolCall = sessionContextToolCall({ conversationFacts, workspace: resolvedWorkspace, traceId });
return readOnlyRunnerResult({
content: sessionContextReply({ conversationFacts, session }),
workspace: resolvedWorkspace,
toolCalls: [contextToolCall],
skills: conversationFacts.latestSkills
? {
status: conversationFacts.latestSkills.status ?? "ready",
items: conversationFacts.latestSkills.names.map((name) => ({ name })),
count: conversationFacts.latestSkills.count ?? conversationFacts.latestSkills.names.length,
totalCount: conversationFacts.latestSkills.totalCount ?? conversationFacts.latestSkills.names.length,
blockers: []
}
: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({
traceId,
workspace: resolvedWorkspace,
session,
events: [...events, "tool:session.context:completed"],
startedAt,
outputTruncated: false
}),
session,
codexStdioFeasibility,
outputTruncated: false,
conversationFacts
});
}
session = registry.fail(session.sessionId, {
now,
traceId,
@@ -1871,6 +1960,9 @@ function detectReadOnlyRunnerIntent(message) {
reason: `只读 runner 不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}`
};
}
if (isSessionContextRequest(text)) {
return { kind: "session_context", toolName: "session.context" };
}
if (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) {
return { kind: "pwd", toolName: "pwd" };
}
@@ -1897,6 +1989,13 @@ function detectReadOnlyRunnerIntent(message) {
return { kind: "none" };
}
function isSessionContextRequest(text) {
const value = String(text ?? "");
const referencesEarlierTurns = /(?:前两轮|前2轮|上一轮|上两轮|刚才|前面|之前|previous|earlier|context|上下文|根据.*(?:结果|内容|事实)|基于.*(?:结果|内容|事实))/iu.test(value);
const asksForFacts = /(?:工作目录|当前目录|workspace|路径|skills?|skill|技能|能使用|可用|session|conversation|会话|trace|runner)/iu.test(value);
return referencesEarlierTurns && asksForFacts;
}
function detectM3IoIntent(text) {
const normalized = String(text ?? "");
if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) {
@@ -2479,7 +2578,7 @@ function notRequestedSkills() {
};
}
function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated }) {
function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated, conversationFacts }) {
return {
provider: READONLY_RUNNER_PROVIDER,
model: READONLY_RUNNER_MODEL,
@@ -2506,6 +2605,7 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
runner,
runnerTrace,
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
...(conversationFacts ? { conversationFacts } : {}),
providerTrace: {
runnerKind: READONLY_RUNNER_KIND,
toolCalls: toolCalls.length,
@@ -2518,6 +2618,55 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
};
}
function sessionContextToolCall({ conversationFacts, workspace, traceId }) {
const bounded = boundToolOutput(JSON.stringify({
conversationId: conversationFacts.conversationId,
sessionId: conversationFacts.sessionId,
turnCount: conversationFacts.turnCount,
workspace: conversationFacts.workspace,
skills: {
status: conversationFacts.latestSkills?.status ?? "not_requested",
count: conversationFacts.latestSkills?.totalCount ?? conversationFacts.latestSkills?.count ?? 0,
names: conversationFacts.latestSkills?.names?.slice(0, 8) ?? []
},
latestTraceId: conversationFacts.latestTraceId,
recentToolCalls: conversationFacts.recentToolCalls?.slice(-6) ?? [],
valuesRedacted: true
}));
return {
id: `tool_${randomUUID()}`,
type: "session-facts",
name: "session.context",
status: "completed",
cwd: workspace,
command: "session.context",
exitCode: 0,
stdout: bounded.text,
stderrSummary: "",
outputTruncated: bounded.truncated,
traceId
};
}
function sessionContextReply({ conversationFacts, session }) {
const skills = conversationFacts.latestSkills;
const skillNames = skills?.names?.length ? skills.names.join(", ") : "上一轮没有可用 skill 清单";
const skillCount = skills ? (skills.totalCount ?? skills.count ?? skills.names?.length ?? 0) : 0;
const toolSummary = conversationFacts.recentToolCalls?.length
? conversationFacts.recentToolCalls.map((toolCall) => `${toolCall.name ?? "tool"}:${toolCall.status ?? "unknown"}`).join(", ")
: "none";
const traceSummary = conversationFacts.traceIds?.length ? conversationFacts.traceIds.join(", ") : "none";
return [
"根据同一个 conversation/session 中已记录的 runner facts",
`- conversationId=${conversationFacts.conversationId}; sessionId=${conversationFacts.sessionId}; sessionStatus=${conversationFacts.sessionStatus ?? session.status}; sessionMode=${conversationFacts.sessionMode ?? session.sessionMode}; turnCount=${conversationFacts.turnCount}.`,
`- 当前工作目录/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。",
limitationReplyLine()
].join("\n");
}
function m3IoSkillArgsForIntent(intent, { env, traceId }) {
const args = [
"m3",
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200;
export const DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS = 12;
export const DEFAULT_CODE_AGENT_MAX_FACT_TOOL_CALLS = 8;
export const DEFAULT_CODE_AGENT_MAX_FACT_SKILLS = 24;
export const CODE_AGENT_SESSION_STATUSES = Object.freeze([
"creating",
"ready",
@@ -15,10 +18,14 @@ export const CODE_AGENT_SESSION_STATUSES = Object.freeze([
export function createCodeAgentSessionRegistry(options = {}) {
const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS);
const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODE_AGENT_MAX_SESSIONS);
const maxConversationFacts = positiveInteger(options.maxConversationFacts, DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS);
const maxFactToolCalls = positiveInteger(options.maxFactToolCalls, DEFAULT_CODE_AGENT_MAX_FACT_TOOL_CALLS);
const maxFactSkills = positiveInteger(options.maxFactSkills, DEFAULT_CODE_AGENT_MAX_FACT_SKILLS);
const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`;
const defaultNow = options.now;
const sessions = new Map();
const conversations = new Map();
const conversationFacts = new Map();
function acquire(params = {}) {
const timestamp = timestampFor(params.now ?? defaultNow);
@@ -166,6 +173,40 @@ export function createCodeAgentSessionRegistry(options = {}) {
return sessionId ? get(sessionId, { ...params, conversationId }) : null;
}
function recordFact(conversationIdValue, fact, params = {}) {
const conversationId = requiredId(conversationIdValue, "cnv");
const timestamp = timestampFor(params.now ?? defaultNow);
const sanitized = sanitizeConversationFact(fact, {
conversationId,
timestamp,
maxFactToolCalls,
maxFactSkills
});
if (!sanitized) return getConversationFacts(conversationId);
const existing = conversationFacts.get(conversationId) ?? {
conversationId,
facts: []
};
existing.facts.push(sanitized);
existing.facts = existing.facts.slice(-maxConversationFacts);
conversationFacts.set(conversationId, existing);
return getConversationFacts(conversationId);
}
function getConversationFacts(conversationIdValue, params = {}) {
const conversationId = requiredId(conversationIdValue, "cnv");
const stored = conversationFacts.get(conversationId);
const facts = stored?.facts ?? [];
const session = getByConversation(conversationId, params);
return publicConversationFacts({
conversationId,
session,
facts,
maxFactToolCalls,
maxConversationFacts
});
}
function expireIdle(params = {}) {
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
@@ -186,7 +227,9 @@ export function createCodeAgentSessionRegistry(options = {}) {
implementationType: "controlled-readonly-session-registry",
status: "available",
sessionCount: sessions.size,
conversationFactCount: conversationFacts.size,
maxSessions,
maxConversationFacts,
idleTimeoutMs,
statuses: [...CODE_AGENT_SESSION_STATUSES],
sessionMode: "controlled-readonly-session-registry",
@@ -205,6 +248,7 @@ export function createCodeAgentSessionRegistry(options = {}) {
function clear() {
sessions.clear();
conversations.clear();
conversationFacts.clear();
}
function prune() {
@@ -217,6 +261,7 @@ export function createCodeAgentSessionRegistry(options = {}) {
if (conversations.get(conversationId) === sessionId) {
conversations.delete(conversationId);
}
conversationFacts.delete(conversationId);
}
}
}
@@ -228,6 +273,8 @@ export function createCodeAgentSessionRegistry(options = {}) {
interrupt,
get,
getByConversation,
recordFact,
getConversationFacts,
expireIdle,
describe,
clear
@@ -290,6 +337,163 @@ function publicSession(session, { conversationId = null, reused = null } = {}) {
};
}
function publicConversationFacts({ conversationId, session, facts, maxFactToolCalls, maxConversationFacts }) {
const latest = facts.at(-1) ?? null;
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 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,
workspace: latestWorkspace,
sandbox: session?.sandbox ?? latest?.sandbox ?? null,
turnCount: facts.length,
maxFacts: maxConversationFacts,
traceIds,
latestTraceId: traceIds.at(-1) ?? session?.lastTraceId ?? null,
latestSkills,
recentToolCalls,
facts: facts.map((fact) => ({ ...fact })),
valuesRedacted: true,
secretMaterialStored: false,
summary: conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls })
};
}
function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactToolCalls, maxFactSkills }) {
if (!fact || typeof fact !== "object") return null;
const session = fact.session && typeof fact.session === "object" ? fact.session : null;
const runner = fact.runner && typeof fact.runner === "object" ? fact.runner : null;
const runnerTrace = fact.runnerTrace && typeof fact.runnerTrace === "object" ? fact.runnerTrace : null;
const toolCalls = sanitizeToolCalls(fact.toolCalls, maxFactToolCalls);
const skills = sanitizeSkillsFact(fact.skills, maxFactSkills);
return {
kind: safeFactText(fact.kind ?? "runner_turn", 80),
conversationId,
sessionId: safeFactText(session?.sessionId ?? fact.sessionId ?? runner?.sessionId, 120),
sessionStatus: safeFactText(session?.status ?? fact.sessionStatus, 40),
sessionMode: safeFactText(fact.sessionMode ?? session?.sessionMode ?? runner?.sessionMode ?? runnerTrace?.sessionMode, 120),
sessionReused: fact.sessionReuse?.reused === true || session?.reused === true || runner?.sessionReused === true,
turn: numberOrNull(session?.turn ?? fact.sessionReuse?.turn ?? runnerTrace?.turn ?? runner?.turn),
recordedAt: timestamp,
traceId: safeFactText(fact.traceId ?? runnerTrace?.traceId, 120),
provider: safeFactText(fact.provider ?? runner?.provider, 120),
backend: safeFactText(fact.backend ?? runner?.backend, 160),
runnerKind: safeFactText(runner?.kind ?? runnerTrace?.runnerKind ?? fact.runnerKind, 120),
workspace: safeFactText(fact.workspace ?? runner?.workspace ?? runnerTrace?.workspace ?? session?.workspace, 240),
sandbox: safeFactText(fact.sandbox ?? runner?.sandbox ?? runnerTrace?.sandbox ?? session?.sandbox, 120),
capabilityLevel: safeFactText(fact.capabilityLevel ?? runner?.capabilityLevel ?? session?.capabilityLevel, 120),
toolCalls,
skills,
runnerTrace: sanitizeRunnerTrace(runnerTrace),
valuesRedacted: true,
secretMaterialStored: false
};
}
function sanitizeToolCalls(toolCalls, limit) {
const items = Array.isArray(toolCalls)
? toolCalls.slice(0, limit).map((toolCall) => ({
name: safeFactText(toolCall?.name, 120),
type: safeFactText(toolCall?.type, 80),
status: safeFactText(toolCall?.status, 40),
route: safeFactText(toolCall?.route ?? toolCall?.hwlabApi?.route, 120),
method: safeFactText(toolCall?.method, 16),
traceId: safeFactText(toolCall?.traceId, 120),
outputTruncated: toolCall?.outputTruncated === true
})).filter((toolCall) => toolCall.name || toolCall.status || toolCall.route)
: [];
return {
count: Array.isArray(toolCalls) ? toolCalls.length : 0,
items,
truncated: Array.isArray(toolCalls) && toolCalls.length > items.length
};
}
function sanitizeSkillsFact(skills, limit) {
if (!skills || typeof skills !== "object") {
return {
status: "not_requested",
count: 0,
totalCount: 0,
names: [],
blockers: [],
truncated: false
};
}
const items = Array.isArray(skills.items) ? skills.items : [];
const names = items
.map((item) => safeFactText(item?.name, 120))
.filter(Boolean)
.slice(0, limit);
return {
status: safeFactText(skills.status ?? "unknown", 40),
count: numberOrNull(skills.count ?? items.length) ?? items.length,
totalCount: numberOrNull(skills.totalCount ?? skills.count ?? items.length) ?? items.length,
names,
blockers: Array.isArray(skills.blockers)
? skills.blockers.map((blocker) => safeFactText(blocker?.code, 120)).filter(Boolean).slice(0, 6)
: [],
truncated: skills.truncated === true || items.length > names.length
};
}
function sanitizeRunnerTrace(runnerTrace) {
if (!runnerTrace || typeof runnerTrace !== "object") return null;
return {
traceId: safeFactText(runnerTrace.traceId, 120),
runnerKind: safeFactText(runnerTrace.runnerKind, 120),
sessionMode: safeFactText(runnerTrace.sessionMode, 120),
sessionId: safeFactText(runnerTrace.sessionId, 120),
turn: numberOrNull(runnerTrace.turn),
events: Array.isArray(runnerTrace.events)
? runnerTrace.events.map((event) => safeFactText(event, 120)).filter(Boolean).slice(-8)
: [],
route: safeFactText(runnerTrace.route, 120),
status: safeFactText(runnerTrace.status, 80),
outputTruncated: runnerTrace.outputTruncated === true,
valuesPrinted: false
};
}
function conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls }) {
const skillNames = latestSkills?.names?.slice(0, 5) ?? [];
const toolNames = recentToolCalls.map((toolCall) => [toolCall.name, toolCall.status].filter(Boolean).join(":")).filter(Boolean).slice(-5);
return [
`turns=${facts.length}`,
`sessionId=${session?.sessionId ?? facts.at(-1)?.sessionId ?? "unknown"}`,
`sessionStatus=${session?.status ?? facts.at(-1)?.sessionStatus ?? "unknown"}`,
latestWorkspace ? `workspace=${latestWorkspace}` : null,
latestSkills ? `skills=${latestSkills.totalCount ?? latestSkills.count ?? skillNames.length}${skillNames.length ? ` (${skillNames.join(",")})` : ""}` : "skills=not_requested",
traceIds.length ? `traceIds=${traceIds.join(",")}` : null,
toolNames.length ? `tools=${toolNames.join(",")}` : null
].filter(Boolean).join(" / ");
}
function safeFactText(value, maxLength) {
if (value === undefined || value === null || value === "") return null;
const normalized = String(value)
.replace(/\s+/gu, " ")
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***")
.replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***")
.replace(/([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Za-z0-9_]*=)[^\s]+/giu, "$1***")
.replace(/\/\/[^/@\s]+@/gu, "//***@")
.trim();
if (!normalized) return null;
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 12)}...[redacted]` : normalized;
}
function numberOrNull(value) {
return Number.isFinite(value) ? value : null;
}
function sessionExpired(session, timestampMs) {
if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired";
return Date.parse(session.expiresAt) <= timestampMs;
@@ -102,6 +102,104 @@ test("code agent session registry creates, reuses, and expires sessions without
assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317");
});
test("code agent session registry stores bounded non-sensitive conversation facts", () => {
const registry = createCodeAgentSessionRegistry({
maxConversationFacts: 2,
maxFactSkills: 2,
idFactory: () => "ses_fact_registry"
});
const acquired = registry.acquire({
conversationId: "cnv_fact_registry",
workspace: "/workspace/hwlab",
traceId: "trc_fact_registry_1",
now: "2026-05-23T00:00:00.000Z"
});
registry.release(acquired.session.sessionId, {
conversationId: "cnv_fact_registry",
traceId: "trc_fact_registry_1",
now: "2026-05-23T00:00:00.010Z"
});
registry.recordFact("cnv_fact_registry", {
traceId: "trc_fact_registry_1",
provider: "codex-readonly-runner",
backend: "hwlab-cloud-api/codex-readonly-runner",
workspace: "/workspace/hwlab",
sandbox: "read-only",
session: acquired.session,
sessionMode: "controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
runner: {
kind: "hwlab-readonly-runner",
sessionId: "ses_fact_registry"
},
runnerTrace: {
traceId: "trc_fact_registry_1",
runnerKind: "hwlab-readonly-runner",
sessionMode: "controlled-readonly-session-registry",
events: ["tool:pwd:completed"],
valuesPrinted: false
},
toolCalls: [{
name: "pwd",
status: "completed",
stdout: "must-not-store-output sk-secret-value OPENAI_API_KEY=secret",
traceId: "trc_fact_registry_1"
}],
skills: {
status: "ready",
items: [
{ name: "alpha" },
{ name: "beta" },
{ name: "gamma" }
],
count: 3,
totalCount: 3
}
}, { now: "2026-05-23T00:00:00.020Z" });
registry.recordFact("cnv_fact_registry", {
traceId: "trc_fact_registry_2",
workspace: "/workspace/hwlab",
runner: { kind: "hwlab-readonly-runner" },
toolCalls: [{
name: "skills.discover",
status: "completed",
stdout: "must-not-store-output sk-secret-value OPENAI_API_KEY=secret"
}],
skills: {
status: "ready",
items: [
{ name: "alpha" },
{ name: "beta" },
{ name: "gamma" }
],
count: 3,
totalCount: 3
}
}, { now: "2026-05-23T00:00:00.030Z" });
registry.recordFact("cnv_fact_registry", {
traceId: "trc_fact_registry_3",
workspace: "/workspace/hwlab",
runner: { kind: "hwlab-readonly-runner" },
toolCalls: [{ name: "session.context", status: "completed" }]
}, { now: "2026-05-23T00:00:00.040Z" });
const facts = registry.getConversationFacts("cnv_fact_registry");
assert.equal(facts.conversationId, "cnv_fact_registry");
assert.equal(facts.sessionId, "ses_fact_registry");
assert.equal(facts.workspace, "/workspace/hwlab");
assert.equal(facts.turnCount, 2);
assert.deepEqual(facts.traceIds, ["trc_fact_registry_2", "trc_fact_registry_3"]);
assert.equal(facts.valuesRedacted, true);
assert.equal(facts.secretMaterialStored, false);
assert.deepEqual(facts.latestSkills.names, ["alpha", "beta"]);
assert.equal(facts.latestSkills.truncated, true);
assert.equal(JSON.stringify(facts).includes("must-not-store-output"), false);
assert.equal(JSON.stringify(facts).includes("sk-secret-value"), false);
assert.equal(JSON.stringify(facts).includes("OPENAI_API_KEY=secret"), false);
assert.match(facts.summary, /workspace=\/workspace\/hwlab/u);
});
test("code agent session registry reports busy sessions as structured blocker", () => {
const registry = createCodeAgentSessionRegistry({
idFactory: () => "ses_busy"
@@ -260,6 +358,15 @@ test("expired, busy, and failed session states are surfaced as structured blocke
traceId: "trc_expired_seed",
now: "2026-05-23T00:02:00.001Z"
});
expiredRegistry.recordFact("cnv_expired_chat", {
traceId: "trc_expired_seed",
workspace: process.cwd(),
session: first.session,
runner: { kind: "hwlab-readonly-runner" },
sessionMode: "controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
toolCalls: [{ name: "pwd", status: "completed" }]
}, { now: "2026-05-23T00:02:00.002Z" });
const expired = await handleCodeAgentChat(
{
conversationId: "cnv_expired_chat",
@@ -285,6 +392,11 @@ test("expired, busy, and failed session states are surfaced as structured blocke
assert.equal(expired.session.status, "expired");
assert.equal(expired.capabilityLevel, "blocked");
assert.equal(expired.longLivedSessionGate.status, "blocked");
assert.equal(expired.conversationFacts.conversationId, "cnv_expired_chat");
assert.equal(expired.conversationFacts.sessionId, "ses_expired_chat");
assert.equal(expired.conversationFacts.turnCount, 1);
assert.equal(expired.conversationFacts.recentToolCalls[0].name, "pwd");
assert.equal(expired.error.blocker.conversationFacts.sessionId, "ses_expired_chat");
const busyRegistry = createCodeAgentSessionRegistry({
idFactory: () => "ses_busy_chat"
@@ -298,6 +410,21 @@ test("expired, busy, and failed session states are surfaced as structured blocke
traceId: "trc_busy_seed",
now: "2026-05-23T00:03:00.000Z"
});
busyRegistry.recordFact("cnv_busy_chat", {
traceId: "trc_busy_seed",
workspace: process.cwd(),
sessionId: "ses_busy_chat",
runner: { kind: "hwlab-readonly-runner" },
sessionMode: "controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
toolCalls: [{ name: "skills.discover", status: "completed" }],
skills: {
status: "ready",
items: [{ name: "alpha-skill" }],
count: 1,
totalCount: 1
}
}, { now: "2026-05-23T00:03:00.001Z" });
const busy = await handleCodeAgentChat(
{
conversationId: "cnv_busy_chat",
@@ -322,6 +449,9 @@ test("expired, busy, and failed session states are surfaced as structured blocke
assert.equal(busy.session.status, "busy");
assert.equal(busy.capabilityLevel, "blocked");
assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317");
assert.equal(busy.conversationFacts.conversationId, "cnv_busy_chat");
assert.equal(busy.conversationFacts.sessionId, "ses_busy_chat");
assert.equal(busy.conversationFacts.latestSkills.names[0], "alpha-skill");
const failedRegistry = createCodeAgentSessionRegistry({
idFactory: () => "ses_failed_chat"
@@ -340,6 +470,15 @@ test("expired, busy, and failed session states are surfaced as structured blocke
traceId: "trc_failed_seed",
now: "2026-05-23T00:04:00.001Z"
});
failedRegistry.recordFact("cnv_failed_chat", {
traceId: "trc_failed_seed",
workspace: process.cwd(),
session: failedSeed.session,
runner: { kind: "hwlab-readonly-runner" },
sessionMode: "controlled-readonly-session-registry",
capabilityLevel: "read-only-session-tools",
toolCalls: [{ name: "pwd", status: "completed" }]
}, { now: "2026-05-23T00:04:00.002Z" });
const failed = await handleCodeAgentChat(
{
conversationId: "cnv_failed_chat",
@@ -363,6 +502,9 @@ test("expired, busy, and failed session states are surfaced as structured blocke
assert.equal(failed.error.blocker.code, "session_failed");
assert.equal(failed.session.status, "failed");
assert.equal(failed.capabilityLevel, "blocked");
assert.equal(failed.conversationFacts.conversationId, "cnv_failed_chat");
assert.equal(failed.conversationFacts.sessionId, "ses_failed_chat");
assert.equal(failed.conversationFacts.recentToolCalls[0].name, "pwd");
});
test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => {
+3 -1
View File
@@ -26,6 +26,7 @@ import {
describeCodeAgentAvailability,
handleCodeAgentChat
} from "./code-agent-chat.mjs";
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs";
import {
applyRuntimeDbReadinessLayers,
@@ -80,6 +81,7 @@ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
export function createCloudApiServer(options = {}) {
const env = options.env ?? process.env;
ensureCodeAgentRuntimeBase(env);
const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry();
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
@@ -87,7 +89,7 @@ export function createCloudApiServer(options = {}) {
});
return createServer(async (request, response) => {
try {
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry });
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry });
} catch (error) {
sendJson(response, 500, {
error: {
+109
View File
@@ -2640,6 +2640,111 @@ test("cloud api /v1/agent/chat reuses controlled read-only session and exposes b
}
});
test("cloud api /v1/agent/chat answers third-turn context from same conversation session facts", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-session-continuity-"));
const workspace = path.join(root, "workspace");
const skillsDir = path.join(root, "skills");
await mkdir(workspace, { recursive: true });
await mkdir(path.join(skillsDir, "alpha"), { recursive: true });
await mkdir(path.join(skillsDir, "beta"), { recursive: true });
await writeFile(path.join(skillsDir, "alpha", "SKILL.md"), [
"---",
"name: alpha-skill",
"description: Alpha skill summary.",
"---",
"",
"# Alpha"
].join("\n"));
await writeFile(path.join(skillsDir, "beta", "SKILL.md"), [
"---",
"name: beta-skill",
"description: Beta skill summary.",
"---",
"",
"# Beta"
].join("\n"));
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_CODE_AGENT_WORKSPACE: workspace,
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "0",
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex"
},
skillsDirs: [skillsDir],
skillsDirsExact: true
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const first = await postAgent(port, {
conversationId: "cnv_server-session-continuity",
traceId: "trc_server-session-continuity-pwd",
message: "用pwd列出你当前的工作目录"
});
assert.equal(first.status, "completed");
assert.equal(first.provider, "codex-readonly-runner");
assert.equal(first.runner.kind, "hwlab-readonly-runner");
assert.equal(first.capabilityLevel, "read-only-session-tools");
assert.equal(first.sessionMode, "controlled-readonly-session-registry");
assert.equal(first.toolCalls[0].name, "pwd");
assert.equal(first.workspace, workspace);
assert.equal(first.conversationFacts.turnCount, 1);
assert.equal(first.conversationFacts.workspace, workspace);
const second = await postAgent(port, {
conversationId: "cnv_server-session-continuity",
traceId: "trc_server-session-continuity-skills",
message: "列出你能使用的所有skill"
});
assert.equal(second.status, "completed");
assert.equal(second.sessionId, first.sessionId);
assert.equal(second.sessionReuse.reused, true);
assert.equal(second.sessionReuse.turn, 2);
assert.equal(second.toolCalls[0].name, "skills.discover");
assert.equal(second.skills.totalCount, 2);
assert.deepEqual(second.skills.items.map((skill) => skill.name), ["alpha-skill", "beta-skill"]);
assert.equal(second.conversationFacts.turnCount, 2);
assert.deepEqual(second.conversationFacts.latestSkills.names, ["alpha-skill", "beta-skill"]);
const third = await postAgent(port, {
conversationId: "cnv_server-session-continuity",
traceId: "trc_server-session-continuity-context",
message: "根据前两轮结果说明你现在在哪个工作目录、能使用哪些skill"
});
assert.equal(third.status, "completed");
assert.equal(third.sessionId, first.sessionId);
assert.equal(third.sessionReuse.reused, true);
assert.equal(third.sessionReuse.turn, 3);
assert.equal(third.provider, "codex-readonly-runner");
assert.equal(third.backend, "hwlab-cloud-api/codex-readonly-runner");
assert.equal(third.runner.kind, "hwlab-readonly-runner");
assert.equal(third.capabilityLevel, "read-only-session-tools");
assert.equal(third.toolCalls[0].name, "session.context");
assert.equal(third.toolCalls[0].status, "completed");
assert.equal(third.conversationFacts.turnCount, 3);
assert.equal(third.conversationFacts.workspace, workspace);
assert.deepEqual(third.conversationFacts.latestSkills.names, ["alpha-skill", "beta-skill"]);
assert.match(third.reply.content, new RegExp(escapeRegExp(workspace), "u"));
assert.match(third.reply.content, /alpha-skill/u);
assert.match(third.reply.content, /beta-skill/u);
assert.match(third.reply.content, /不是 openai-responses-fallback/u);
assert.match(third.reply.content, /不是 stateless-one-shot/u);
assert.notEqual(third.provider, "openai-responses");
assert.notEqual(third.runner.kind, "openai-responses-fallback");
assert.notEqual(third.runner.kind, "codex-cli-one-shot-ephemeral");
assert.notEqual(third.sessionMode, "ephemeral-one-shot");
assert.ok(third.runnerTrace.events.includes("tool:session.context:completed"));
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await rm(root, { recursive: true, force: true });
}
});
test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-"));
const server = createCloudApiServer({
@@ -3574,6 +3679,10 @@ async function postAgentRaw(port, body, headers = {}) {
};
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
function createGreenM3StatusRuntimeStore({ blocker = null } = {}) {
return {
async readiness() {
+39 -3
View File
@@ -281,7 +281,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
implementationType: null,
runnerLimitations: [],
codexStdioFeasibility: null,
longLivedSessionGate: null
longLivedSessionGate: null,
conversationFacts: null
};
}
@@ -329,7 +330,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
implementationType: stringOrNull(payload.implementationType),
runnerLimitations: summarizeStringList(payload.runnerLimitations),
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate)
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate),
conversationFacts: summarizeConversationFacts(payload.conversationFacts)
};
}
@@ -357,7 +359,8 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
implementationType: stringOrNull(payload.implementationType),
runnerLimitations: summarizeStringList(payload.runnerLimitations),
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate)
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate),
conversationFacts: summarizeConversationFacts(payload.conversationFacts)
};
}
@@ -793,6 +796,39 @@ function summarizeSession(value) {
};
}
function summarizeConversationFacts(value) {
if (!value || typeof value !== "object") return null;
return {
conversationId: stringOrNull(value.conversationId),
sessionId: stringOrNull(value.sessionId),
sessionStatus: stringOrNull(value.sessionStatus),
sessionMode: stringOrNull(value.sessionMode),
capabilityLevel: stringOrNull(value.capabilityLevel),
runnerKind: stringOrNull(value.runnerKind),
workspace: stringOrNull(value.workspace),
sandbox: stringOrNull(value.sandbox),
turnCount: typeof value.turnCount === "number" ? value.turnCount : null,
latestTraceId: stringOrNull(value.latestTraceId),
traceIds: Array.isArray(value.traceIds) ? value.traceIds.map(stringOrNull).filter(Boolean).slice(-6) : [],
latestSkills: value.latestSkills && typeof value.latestSkills === "object"
? {
status: stringOrNull(value.latestSkills.status),
count: typeof value.latestSkills.count === "number" ? value.latestSkills.count : null,
totalCount: typeof value.latestSkills.totalCount === "number" ? value.latestSkills.totalCount : null,
names: Array.isArray(value.latestSkills.names) ? value.latestSkills.names.map(stringOrNull).filter(Boolean).slice(0, 8) : []
}
: null,
recentToolCalls: Array.isArray(value.recentToolCalls)
? value.recentToolCalls.map((tool) => ({
name: stringOrNull(tool?.name),
status: stringOrNull(tool?.status)
})).filter((tool) => tool.name || tool.status).slice(-8)
: [],
valuesRedacted: value.valuesRedacted === true,
secretMaterialStored: value.secretMaterialStored === true
};
}
function summarizeCodexStdioFeasibility(value) {
if (!value || typeof value !== "object") return null;
return {
+61 -7
View File
@@ -4214,6 +4214,14 @@ async function inspectJourneyUi(page) {
[...document.querySelectorAll(".message-card.message-agent .message-attribution")]
.map((element) => element.textContent ?? "")
.join("\n")
),
conversationFactsVisible: /conversationFacts|fact traces|fact skills|fact tools|turns=|sessionId=|workspace=/u.test(
[
...document.querySelectorAll(".message-card.message-agent .code-agent-facts"),
document.querySelector("#code-agent-summary")
]
.map((element) => element?.textContent ?? "")
.join("\n")
)
};
});
@@ -4589,6 +4597,7 @@ async function runLocalAgentFixtureSmoke({
ui.attributionShowsToolCalls &&
ui.attributionMissingFieldsStructured &&
ui.attributionFallbackNotRunnerControl &&
ui.conversationFactsVisible &&
!ui.completedMessageVisible &&
!ui.failedMessageVisible
);
@@ -5867,9 +5876,27 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
await delay(options.agentDelayMs ?? 0);
const timestamp = "2026-05-22T00:00:00.000Z";
const conversationId = stringOrFallback(body?.conversationId, "cnv_source_fixture_browser");
const traceId = stringOrFallback(body?.traceId, "trc_source_fixture_browser");
const normalizedMessage = String(body?.message ?? "");
const isSkillListPrompt = /列出你能使用的所有skill/u.test(normalizedMessage);
const messageId = isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple";
const fixtureSkills = isSkillListPrompt
? {
status: "ready",
items: [
{ name: "source-fixture-skill" }
],
count: 1,
totalCount: 1,
blockers: []
}
: {
status: "not_requested",
count: 0,
totalCount: 0,
items: [],
blockers: []
};
jsonResponse(response, 200, {
conversationId,
sessionId: conversationId,
@@ -5879,7 +5906,7 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
evidenceLevel: "SOURCE",
createdAt: timestamp,
updatedAt: timestamp,
traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"),
traceId,
provider: "source-fixture",
model: "gpt-source-fixture",
backend: "local-source-fixture",
@@ -5894,16 +5921,43 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
capabilityLevel: "text-chat-only",
sessionMode: "provider-text-request",
toolCalls: [],
skills: {
status: "not_requested",
count: 0,
items: []
},
skills: fixtureSkills,
runnerTrace: {
traceId: stringOrFallback(body?.traceId, "trc_source_fixture_browser"),
traceId,
runnerKind: "openai-responses-fallback",
events: ["fixture:text-chat-only"]
},
conversationFacts: {
conversationId,
sessionId: conversationId,
sessionStatus: "idle",
sessionMode: "provider-text-request",
capabilityLevel: "text-chat-only",
runnerKind: "openai-responses-fallback",
workspace: "/workspace/hwlab",
sandbox: "none",
turnCount: isSkillListPrompt ? 2 : 1,
latestTraceId: traceId,
traceIds: isSkillListPrompt ? ["trc_source_fixture_browser_simple", traceId] : [traceId],
latestSkills: isSkillListPrompt
? {
status: "ready",
count: 1,
totalCount: 1,
names: ["source-fixture-skill"],
blockers: []
}
: null,
recentToolCalls: isSkillListPrompt
? [{ name: "skills.discover", status: "completed" }]
: [],
facts: [],
valuesRedacted: true,
secretMaterialStored: false,
summary: isSkillListPrompt
? `turns=2 / sessionId=${conversationId} / workspace=/workspace/hwlab / skills=1 (source-fixture-skill) / traceIds=trc_source_fixture_browser_simple,${traceId}`
: `turns=1 / sessionId=${conversationId} / workspace=/workspace/hwlab / skills=not_requested / traceIds=${traceId}`
},
reply: {
messageId,
role: "assistant",
+66 -1
View File
@@ -675,6 +675,7 @@ function initCommandBar() {
skills: result.skills,
runner: result.runner,
runnerTrace: result.runnerTrace,
conversationFacts: result.conversationFacts,
m3Evidence: extractCodeAgentM3Evidence(result),
capabilityLevel: result.capabilityLevel,
sourceKind: completion.sourceKind,
@@ -2355,6 +2356,8 @@ function codeAgentSummaryRows(summary) {
codeAgentSummaryRow("runner", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"),
codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone),
codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone),
codeAgentSummaryRow("conversation facts", conversationFactsSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"),
codeAgentSummaryRow("fact traces", conversationFactTracesSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"),
codeAgentSummaryRow("runnerTrace", summary.runnerTrace, "source"),
codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"),
codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"),
@@ -2690,7 +2693,7 @@ function codeAgentCapabilityFactsPanel(message) {
const grid = document.createElement("dl");
grid.className = "code-agent-fact-grid";
for (const row of facts.rows) {
for (const row of [...facts.rows, ...conversationFactRows(message.conversationFacts)]) {
const item = document.createElement("div");
item.className = "code-agent-fact-row";
item.append(textSpan(row.label, "code-agent-fact-key"), textSpan(row.value ?? "none", "code-agent-fact-value mono"));
@@ -2801,6 +2804,8 @@ function messageEvidenceFields(message) {
recordField("toolCalls", toolCallsSummary(message.toolCalls)),
recordField("skills", skillsSummary(message.skills)),
recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)),
recordField("conversationFacts", conversationFactsSummary(message.conversationFacts)),
recordField("factTraces", conversationFactTracesSummary(message.conversationFacts)),
recordField("operationId", operationIdSummary(message)),
recordField("audit", auditSummary(message)),
recordField("evidence", evidenceSummary(message)),
@@ -2928,6 +2933,66 @@ function runnerTraceSummary(runnerTrace) {
return summary === "none" ? null : summary;
}
function conversationFactRows(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object") return [];
return [
{
label: "conversationFacts",
value: conversationFactsSummary(conversationFacts)
},
{
label: "fact traces",
value: conversationFactTracesSummary(conversationFacts)
},
{
label: "fact skills",
value: conversationFactSkillsSummary(conversationFacts)
},
{
label: "fact tools",
value: conversationFactToolsSummary(conversationFacts)
}
];
}
function conversationFactsSummary(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object") return "none";
return [
recordField("turns", conversationFacts.turnCount),
recordField("sessionId", conversationFacts.sessionId),
recordField("status", conversationFacts.sessionStatus),
recordField("workspace", conversationFacts.workspace),
recordField("mode", conversationFacts.sessionMode),
conversationFacts.valuesRedacted === true ? "valuesRedacted=true" : null
].filter(Boolean).join(" / ") || "none";
}
function conversationFactTracesSummary(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object") return "none";
const traces = Array.isArray(conversationFacts.traceIds) ? conversationFacts.traceIds.filter(Boolean) : [];
return traces.length ? traces.slice(-4).join(",") : conversationFacts.latestTraceId ?? "none";
}
function conversationFactSkillsSummary(conversationFacts) {
const skills = conversationFacts?.latestSkills;
if (!skills || typeof skills !== "object") return "none";
const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 6).join(",") : "";
return [
skills.status ?? "unknown",
recordField("count", skills.totalCount ?? skills.count),
names ? `items=${names}` : null
].filter(Boolean).join(":") || "none";
}
function conversationFactToolsSummary(conversationFacts) {
const toolCalls = Array.isArray(conversationFacts?.recentToolCalls) ? conversationFacts.recentToolCalls : [];
if (toolCalls.length === 0) return "none";
return toolCalls
.map((toolCall) => [toolCall.name ?? "tool", toolCall.status ? `(${toolCall.status})` : null].filter(Boolean).join(""))
.slice(-5)
.join(",");
}
function sessionReuseSummary(sessionReuse) {
if (!sessionReuse || typeof sessionReuse !== "object") return null;
const state = sessionReuse.reused ? "reused" : "new";
+9
View File
@@ -753,6 +753,7 @@ assert.match(app, /implementationType:\s*result\.implementationType/);
assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/);
assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/);
assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/);
assert.match(app, /conversationFacts:\s*result\.conversationFacts/);
assert.match(app, /providerTraceSummary\(message\.providerTrace\)/);
assert.match(app, /recordField\("conversation", message\.conversationId\)/);
assert.match(app, /recordField\("provider", message\.provider\)/);
@@ -766,14 +767,22 @@ assert.match(app, /recordField\("implementation", message\.implementationType\)/
assert.match(app, /recordField\("limitations", limitationsSummary\(message\.runnerLimitations\)\)/);
assert.match(app, /recordField\("codexStdio", codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/);
assert.match(app, /recordField\("longLivedGate", longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/);
assert.match(app, /recordField\("conversationFacts", conversationFactsSummary\(message\.conversationFacts\)\)/);
assert.match(app, /recordField\("factTraces", conversationFactTracesSummary\(message\.conversationFacts\)\)/);
assert.match(app, /codeAgentSummaryRow\("sessionId\/status", summary\.sessionIdStatus/);
assert.match(app, /codeAgentSummaryRow\("workspace", summary\.workspace/);
assert.match(app, /codeAgentSummaryRow\("sandbox", summary\.sandbox/);
assert.match(app, /codeAgentSummaryRow\("runner", summary\.runnerKind/);
assert.match(app, /codeAgentSummaryRow\("toolCalls", summary\.toolCalls/);
assert.match(app, /codeAgentSummaryRow\("skills", summary\.skills/);
assert.match(app, /codeAgentSummaryRow\("conversation facts", conversationFactsSummary\(summary\.latestMessage\?\.conversationFacts\)/);
assert.match(app, /codeAgentSummaryRow\("fact traces", conversationFactTracesSummary\(summary\.latestMessage\?\.conversationFacts\)/);
assert.match(app, /codeAgentSummaryRow\("runnerTrace", summary\.runnerTrace/);
assert.match(app, /function sessionSummary/);
assert.match(app, /function conversationFactRows/);
assert.match(app, /function conversationFactsSummary/);
assert.match(app, /function conversationFactSkillsSummary/);
assert.match(app, /function conversationFactToolsSummary/);
assert.match(app, /function longLivedSessionGateSummary/);
assert.match(app, /read-only-session-tools/);
assert.match(app, /controlled-readonly-session-registry/);