diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index ff70548d..937f37d0 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -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, diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 9f199e2d..de9a3e18 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -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", diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 04270ac1..78a57b89 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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 () => { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index dbe494ce..28d6bc5a 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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);