Merge pull request #783 from pikasTech/fix-v02-cli-explicit-session

fix: align v0.2 explicit agent session dispatch
This commit is contained in:
Lyon
2026-06-03 22:52:49 +08:00
committed by GitHub
4 changed files with 147 additions and 13 deletions
@@ -478,7 +478,7 @@ async function ensureAgentRunSessionPersistent({ fetchImpl, managerUrl, sessionI
if (defaultPolicy !== "persistent") return;
try {
const tenantId = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TENANT_ID, DEFAULT_TENANT_ID);
const projectId = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECT_ID, DEFAULT_PROJECT_ID);
const projectId = agentRunProjectIdForEnv(env);
const expiresInDays = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_SESSION_TTL_DAYS, 30);
const expiresAt = new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString();
await agentRunJson(fetchImpl, managerUrl, "/api/v1/sessions", {
@@ -506,6 +506,10 @@ async function ensureAgentRunSessionPersistent({ fetchImpl, managerUrl, sessionI
}
}
function agentRunProjectIdForEnv(env = process.env) {
return firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECT_ID, DEFAULT_PROJECT_ID);
}
async function shouldResetSessionAfterEviction(failureKind, failureMessage) {
return failureKind === "session-store-evicted" || /session store evicted/i.test(failureMessage ?? "");
}
@@ -517,6 +521,7 @@ function newSessionIdAfterEviction(baseSessionId, traceId) {
function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId }) {
const commitId = fullSourceCommit(env);
const threadId = safeOpaqueId(params.threadId);
const hwlabProjectId = firstNonEmpty(params.projectId);
const resourceBundleRef = commitId
? {
kind: "git",
@@ -531,7 +536,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
: null;
return {
tenantId: firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TENANT_ID, DEFAULT_TENANT_ID),
projectId: firstNonEmpty(params.projectId, env.HWLAB_CODE_AGENT_AGENTRUN_PROJECT_ID, DEFAULT_PROJECT_ID),
projectId: agentRunProjectIdForEnv(env),
workspaceRef: {
kind: "opaque",
repo: DEFAULT_PROJECT_ID,
@@ -548,6 +553,7 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses
adapter: ADAPTER_ID,
hwlabTraceId: traceId,
hwlabApi: "/v1/agent/chat",
hwlabProjectId,
hwlabSessionId: safeSessionId(params.sessionId) || null,
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
sessionPolicy: SESSION_POLICY_RUN_LOCAL,
@@ -589,6 +595,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId
prompt,
message: prompt,
traceId,
projectId: firstNonEmpty(params.projectId) || null,
conversationId: safeConversationId(params.conversationId) || null,
sessionId: sessionId ?? scopedAgentRunSessionIdForParams(params, traceId, backendProfile),
hwlabSessionId: safeSessionId(params.sessionId) || null,
+6 -3
View File
@@ -197,6 +197,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
}
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
assert.equal(body.tenantId, "hwlab");
assert.equal(body.projectId, "pikasTech/HWLAB");
assert.equal(body.backendProfile, "deepseek");
assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git");
assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567");
@@ -216,6 +217,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && item.projection.envName === "UNIDESK_SSH_CLIENT_TOKEN" && item.secretRef.name === "agentrun-v01-tool-unidesk-ssh"), true);
assert.equal(body.sessionRef.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(body.sessionRef.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.sessionRef.metadata.hwlabProjectId, "prj_device_pod_workbench");
assert.equal(body.sessionRef.metadata.hwlabSessionId, "ses_server-test-agentrun");
assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "deepseek");
assert.equal(body.sessionRef.metadata.agentRunSessionPolicy, "backend-profile-scoped");
@@ -237,6 +239,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
}
assert.equal(body.type, "turn");
assert.match(body.payload.prompt, /AgentRun adapter/u);
assert.equal(body.payload.projectId, "prj_device_pod_workbench");
assert.equal(body.payload.sessionId, "ses_agentrun_deepseek_server_test_agentrun");
assert.equal(body.payload.hwlabSessionId, "ses_server-test-agentrun");
assert.equal(body.payload.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
@@ -419,7 +422,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
conversationId: "cnv_server-test-agentrun",
projectId: "pikasTech/HWLAB",
projectId: "prj_device_pod_workbench",
sessionId: "ses_server-test-agentrun",
ownerUserId: "usr_agent_owner",
ownerRole: "user",
@@ -514,7 +517,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
traceId,
steerTraceId: "trc_steer_server_test",
conversationId: "cnv_server-test-agentrun",
projectId: "pikasTech/HWLAB",
projectId: "prj_device_pod_workbench",
sessionId: "ses_server-test-agentrun",
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
message: "请按 STEER_MARK 调整最终回复"
@@ -537,7 +540,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
headers: { "content-type": "application/json", "x-trace-id": secondTraceId, cookie: "hwlab_session=test-stub-session" },
body: JSON.stringify({
conversationId: "cnv_server-test-agentrun",
projectId: "pikasTech/HWLAB",
projectId: "prj_device_pod_workbench",
sessionId: "ses_server-test-agentrun",
ownerUserId: "usr_agent_owner",
ownerRole: "user",
+93 -2
View File
@@ -529,6 +529,77 @@ test("hwlab-cli client agent send does not inherit workspace session for explici
assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
});
test("hwlab-cli client agent send resolves explicit session conversation before using workspace conversation", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"send",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"send through explicit session",
"--trace-id",
"trc_explicit_session",
"--session-id",
"ses_manual_current"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) {
return new Response(JSON.stringify({
ok: true,
workspace: {
workspaceId: "wsp_stale_selection",
revision: 12,
selectedConversationId: "cnv_stale_workspace",
selectedAgentSessionId: "ses_stale_workspace",
selectedConversation: {
conversationId: "cnv_stale_workspace",
sessionId: "ses_stale_workspace",
threadId: "thread-stale-workspace"
},
workspace: { sessionStatus: "idle", threadId: "thread-stale-workspace" }
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_manual_current")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_manual_current",
conversationId: "cnv_manual_current",
threadId: "thread-manual-current",
status: "idle",
usable: true,
providerProfile: "minimax-m3"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_explicit_session", resultUrl: "/v1/agent/chat/result/trc_explicit_session" }), { status: 202 });
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_explicit_session", conversationId: "cnv_manual_current", sessionId: "ses_manual_current", reply: { role: "assistant", content: "explicit session ok" } }), { status: 200 });
},
sleep: async () => {}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
assert.equal(calls[1].url, "http://web.test/v1/agent/sessions/ses_manual_current");
assert.equal(calls[2].url, "http://web.test/v1/agent/chat");
assert.equal(calls[2].body.conversationId, "cnv_manual_current");
assert.equal(calls[2].body.sessionId, "ses_manual_current");
assert.equal(calls[2].body.threadId, "thread-manual-current");
assert.equal(calls[2].body.workspaceId, "wsp_stale_selection");
assert.equal(result.payload.continuation.conversationId, "cnv_manual_current");
assert.equal(result.payload.continuation.sessionId, "ses_manual_current");
assert.equal(result.payload.continuation.threadId, "thread-manual-current");
});
test("hwlab-cli client agent send prefers selected conversation thread over stale workspace thread", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
@@ -805,7 +876,24 @@ test("hwlab-cli client agent send restores workspace when completed patch omits
return new Response(JSON.stringify({ status: "completed", traceId: "trc_restore_workspace", conversationId: "cnv_restore_workspace", sessionId: "ses_restore_workspace", reply: { role: "assistant", content: "restore workspace ready" } }), { status: 200 });
}
if (String(url).endsWith("/v1/workbench/workspace/wsp_restore_workspace")) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
return new Response(JSON.stringify({
ok: true,
workspace: {
workspaceId: "wsp_restore_workspace",
revision: 12,
selectedConversationId: "cnv_restore_workspace",
selectedAgentSessionId: "ses_restore_workspace",
selectedConversation: {
conversationId: "cnv_previous_workspace",
sessionId: "ses_previous_workspace",
threadId: "thread-previous-workspace"
},
workspace: {
sessionStatus: "completed",
threadId: "thread-restore-workspace"
}
}
}), { status: 200 });
}
return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 });
},
@@ -817,15 +905,18 @@ test("hwlab-cli client agent send restores workspace when completed patch omits
assert.equal(calls[1].body.sessionId, "ses_restore_workspace");
assert.equal(Object.hasOwn(calls[1].body, "threadId"), false);
assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_restore_workspace");
assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
assert.equal(result.payload.workspace.selectedConversationId, "cnv_restore_workspace");
assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_restore_workspace");
assert.equal(result.payload.workspace.threadId, "thread-restore-workspace");
assert.equal(result.payload.workspace.selectedConversation.conversationId, "cnv_restore_workspace");
assert.equal(result.payload.workspace.selectedConversation.sessionId, "ses_restore_workspace");
const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8"));
assert.equal(session.workspace.selectedConversationId, "cnv_restore_workspace");
assert.equal(session.workspace.selectedAgentSessionId, "ses_restore_workspace");
assert.equal(session.workspace.threadId, "thread-restore-workspace");
assert.equal(session.workspace.selectedConversation.conversationId, "cnv_restore_workspace");
assert.equal(session.workspace.selectedConversation.sessionId, "ses_restore_workspace");
});
test("hwlab-cli client agent send output uses completed result when restore is stale", async () => {
+39 -6
View File
@@ -865,6 +865,14 @@ async function agentSessionStatus(context: any) {
return responsePayload("client.agent.session.status", response, context, { route: route("GET", pathName), ...agentSessionSummary(response.body?.session, { sessionId }), body: responseBodyForCli(response.body, context.parsed) });
}
async function fetchAgentSessionForSend(context: any, sessionId: string) {
const pathName = `/v1/agent/sessions/${encodeURIComponent(sessionId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
if (!isHttpSuccess(response)) return { response, session: null };
const session = response.body?.session && typeof response.body.session === "object" ? response.body.session : null;
return { response, session };
}
async function agentSessionList(context: any) {
const params = new URLSearchParams();
params.set("projectId", text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID);
@@ -1297,9 +1305,18 @@ async function agentSend(context: any) {
const requestedConversationId = explicitConversationId || replayConversationId;
const workspaceConversationId = text(workspaceState?.selectedConversationId);
const explicitSessionId = text(parsed.sessionId);
let explicitSessionRecord = null;
if (explicitSessionId && !requestedConversationId) {
const lookup = await fetchAgentSessionForSend(context, explicitSessionId);
if (!isHttpSuccess(lookup.response)) {
return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", `/v1/agent/sessions/${encodeURIComponent(explicitSessionId)}`), traceId, sessionId: explicitSessionId, replay: replay.summary });
}
explicitSessionRecord = lookup.session;
}
const explicitSessionConversationId = text(explicitSessionRecord?.conversationId);
const allowWorkspaceSession = !requestedConversationId || requestedConversationId === workspaceConversationId;
const sessionId = explicitSessionId || replay.sessionId || (allowWorkspaceSession ? text(workspaceState?.selectedAgentSessionId) : "");
const conversationId = requestedConversationId || workspaceConversationId;
const conversationId = requestedConversationId || explicitSessionConversationId || workspaceConversationId;
const workspaceSessionId = text(workspaceState?.selectedAgentSessionId);
const workspaceMatchesSelectedSession = workspaceSessionId === sessionId && (!conversationId || workspaceConversationId === conversationId);
if (!sessionId) {
@@ -1312,7 +1329,7 @@ async function agentSend(context: any) {
]
});
}
const sessionStatus = workspaceSessionId === sessionId ? text(workspaceState?.sessionStatus) : "";
const sessionStatus = explicitSessionRecord ? text(explicitSessionRecord.status) : (workspaceSessionId === sessionId ? text(workspaceState?.sessionStatus) : "");
if (isCodeAgentSessionUnusableStatus(sessionStatus) && !text(parsed.ignoreSessionStatus)) {
throw cliError("session_not_usable", `client agent send refuses to reuse ${sessionStatus} Code Agent session; create/select a new session first`, {
traceId,
@@ -1329,7 +1346,7 @@ async function agentSend(context: any) {
nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"]
});
}
const threadId = text(parsed.threadId) || replay.threadId || (workspaceMatchesSelectedSession ? text(workspaceState?.threadId) : "");
const threadId = text(parsed.threadId) || replay.threadId || (explicitSessionRecord ? text(explicitSessionRecord.threadId) : "") || (workspaceMatchesSelectedSession ? text(workspaceState?.threadId) : "");
const retryOf = text(parsed.retryOf) || replay.retryOf;
const continuation = agentContinuationSummary({
conversationId,
@@ -2150,8 +2167,9 @@ function workspaceWithAgentResult(workspace: any, resultBody: any, fallback: any
const resultThreadId = text(resultBody?.threadId ?? resultBody?.session?.threadId ?? resultBody?.providerTrace?.threadId ?? fallback.threadId);
const baseConversationMatches = !conversationId || text(base.selectedConversationId) === conversationId;
const baseSessionMatches = !sessionId || text(base.selectedAgentSessionId) === sessionId;
const nestedConversationMatches = selectedConversationMatches(base.selectedConversation, { conversationId, sessionId });
const baseThreadId = baseConversationMatches && baseSessionMatches ? text(base.threadId) : "";
const selectedConversation = baseConversationMatches && baseSessionMatches && base.selectedConversation
const selectedConversation = baseConversationMatches && baseSessionMatches && nestedConversationMatches
? base.selectedConversation
: clean({ conversationId, sessionId, threadId: resultThreadId || baseThreadId });
return clean({
@@ -2166,6 +2184,13 @@ function workspaceWithAgentResult(workspace: any, resultBody: any, fallback: any
});
}
function selectedConversationMatches(selectedConversation: any, expected: any) {
if (!selectedConversation || typeof selectedConversation !== "object") return false;
const conversationId = text(expected.conversationId);
const sessionId = text(expected.sessionId);
return (!conversationId || text(selectedConversation.conversationId) === conversationId) && (!sessionId || text(selectedConversation.sessionId ?? selectedConversation.session?.sessionId) === sessionId);
}
async function saveWorkspaceState(context: any, workspaceBody: any) {
const session = await loadStoredState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }) ?? {};
await saveSession(context, { ...session, baseUrl: baseUrl(context.parsed, context.env), workspace: normalizeWorkbenchWorkspace(workspaceBody), updatedAt: context.now() });
@@ -2223,9 +2248,17 @@ function workbenchThreadId(workspace: any, workspaceJson: any) {
const selectedAgentSession = workspace?.selectedAgentSession && typeof workspace.selectedAgentSession === "object" ? workspace.selectedAgentSession : {};
const selectedConversationJson = workspaceJson?.selectedConversation && typeof workspaceJson.selectedConversation === "object" ? workspaceJson.selectedConversation : {};
const selectedAgentSessionJson = workspaceJson?.selectedAgentSession && typeof workspaceJson.selectedAgentSession === "object" ? workspaceJson.selectedAgentSession : {};
return text(selectedConversation.threadId ?? selectedConversation.session?.threadId)
const topConversationId = text(workspace?.selectedConversationId ?? workspaceJson?.selectedConversationId);
const topSessionId = text(workspace?.selectedAgentSessionId ?? workspaceJson?.selectedAgentSessionId);
const selectedConversationThreadId = selectedConversationMatches(selectedConversation, { conversationId: topConversationId, sessionId: topSessionId })
? text(selectedConversation.threadId ?? selectedConversation.session?.threadId)
: "";
const selectedConversationJsonThreadId = selectedConversationMatches(selectedConversationJson, { conversationId: topConversationId, sessionId: topSessionId })
? text(selectedConversationJson.threadId ?? selectedConversationJson.session?.threadId)
: "";
return selectedConversationThreadId
|| text(selectedAgentSession.threadId)
|| text(selectedConversationJson.threadId ?? selectedConversationJson.session?.threadId)
|| selectedConversationJsonThreadId
|| text(selectedAgentSessionJson.threadId)
|| text(workspaceJson?.threadId)
|| text(workspace?.threadId);