feat: persist code agent session facts
This commit is contained in:
@@ -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-runner,capabilityLevel=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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user