From 78a6b82751b4fbdf96db0ed1559d1b9b12b6f354 Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 15:53:44 +0800 Subject: [PATCH] fix: remove workspace session fallback from hwlab cli --- tools/hwlab-cli/client.test.ts | 107 ++++++++++++++++--------- tools/src/hwlab-cli-lib.ts | 71 +++++++++------- tools/src/hwlab-cli/composer-policy.ts | 6 +- 3 files changed, 116 insertions(+), 68 deletions(-) diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 6217c165..5a306421 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -596,6 +596,9 @@ test("hwlab-cli client agent send submits async and returns trace without waitin if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_hwpod_workbench")) { return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_test", revision: 3, selectedConversationId: "cnv_test", selectedAgentSessionId: "ses_test", selectedConversation: { conversationId: "cnv_test", sessionId: "ses_test", threadId: "thread-test" }, workspace: {} } }), { status: 200 }); } + if (String(url).endsWith("/v1/agent/conversations/cnv_test?projectId=prj_hwpod_workbench")) { + return new Response(JSON.stringify({ ok: true, conversation: { conversationId: "cnv_test", sessionId: "ses_test", threadId: "thread-test", providerProfile: "deepseek", status: "idle" } }), { status: 200 }); + } if (String(url).endsWith("/v1/agent/chat")) { return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_test", resultUrl: "/v1/agent/chat/result/trc_test" }), { status: 202 }); } @@ -606,16 +609,17 @@ test("hwlab-cli client agent send submits async and returns trace without waitin assert.equal(result.exitCode, 0); assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); - assert.equal(calls[1].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[1].init.headers.prefer, "respond-async"); - assert.equal(calls[1].body.shortConnection, true); - assert.equal(calls[1].body.workspaceId, "wsp_test"); - assert.equal(calls[1].body.expectedWorkspaceRevision, 3); - assert.equal(calls[1].body.sessionId, "ses_test"); - assert.equal(calls[1].body.threadId, "thread-test"); - assert.equal(Object.hasOwn(calls[1].body, "conversationContext"), false); - assert.equal(Object.hasOwn(calls[1].body, "messages"), false); - assert.equal(calls.length, 2); + assert.equal(calls[1].url, "http://web.test/v1/agent/conversations/cnv_test?projectId=prj_hwpod_workbench"); + assert.equal(calls[2].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[2].init.headers.prefer, "respond-async"); + assert.equal(calls[2].body.shortConnection, true); + assert.equal(calls[2].body.workspaceId, "wsp_test"); + assert.equal(calls[2].body.expectedWorkspaceRevision, 3); + assert.equal(calls[2].body.sessionId, "ses_test"); + assert.equal(calls[2].body.threadId, "thread-test"); + assert.equal(Object.hasOwn(calls[2].body, "conversationContext"), false); + assert.equal(Object.hasOwn(calls[2].body, "messages"), false); + assert.equal(calls.length, 3); assert.equal(result.payload.traceId, "trc_test"); assert.equal(result.payload.continuation.threadId, "thread-test"); assert.equal(result.payload.waited, false); @@ -735,15 +739,19 @@ test("hwlab-cli client agent send does not inherit workspace session for explici } }), { status: 200 }); } - return new Response(JSON.stringify({ accepted: true }), { status: 202 }); + if (String(url).endsWith("/v1/agent/conversations/cnv_new_conversation?projectId=prj_hwpod_workbench")) { + return new Response(JSON.stringify({ ok: true, conversation: { conversationId: "cnv_new_conversation" } }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); } }); assert.equal(result.exitCode, 1); assert.equal(result.payload.error.code, "session_required"); assert.equal(result.payload.error.details.conversationId, "cnv_new_conversation"); - assert.equal(calls.length, 1); + assert.equal(calls.length, 2); assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); + assert.equal(calls[1].url, "http://web.test/v1/agent/conversations/cnv_new_conversation?projectId=prj_hwpod_workbench"); }); test("hwlab-cli client agent send resolves explicit session conversation before using workspace conversation", async () => { @@ -819,7 +827,7 @@ test("hwlab-cli client agent send resolves explicit session conversation before assert.equal(result.payload.continuation.threadId, "thread-manual-current"); }); -test("hwlab-cli client agent send prefers selected conversation thread over stale workspace thread", async () => { +test("hwlab-cli client agent send does not use matching workspace session as implicit context", async () => { const calls: any[] = []; const result = await runHwlabCli([ "client", @@ -857,21 +865,20 @@ test("hwlab-cli client agent send prefers selected conversation thread over stal } }), { status: 200 }); } - if (String(url).endsWith("/v1/agent/chat")) { - return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_thread_current", resultUrl: "/v1/agent/chat/result/trc_thread_current" }), { status: 202 }); + if (String(url).endsWith("/v1/agent/conversations/cnv_thread_current?projectId=prj_hwpod_workbench")) { + return new Response(JSON.stringify({ ok: true, conversation: { conversationId: "cnv_thread_current" } }), { status: 200 }); } - return new Response(JSON.stringify({ status: "completed", traceId: "trc_thread_current", conversationId: "cnv_thread_current", reply: { role: "assistant", content: "ok" } }), { status: 200 }); + return new Response(JSON.stringify({ ok: false, error: { code: "unexpected_test_route" } }), { status: 404 }); }, sleep: async () => {} }); - assert.equal(result.exitCode, 0); - assert.equal(calls[1].url, "http://web.test/v1/agent/chat"); - assert.equal(calls[1].body.conversationId, "cnv_thread_current"); - assert.equal(calls[1].body.sessionId, "ses_thread_current"); - assert.equal(calls[1].body.threadId, "thread-current"); - assert.equal(result.payload.continuation.threadId, "thread-current"); - assert.equal(result.payload.workspace.threadId, "thread-current"); + assert.equal(result.exitCode, 1); + assert.equal(result.payload.error.code, "session_required"); + assert.equal(result.payload.error.details.conversationId, "cnv_thread_current"); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_hwpod_workbench"); + assert.equal(calls[1].url, "http://web.test/v1/agent/conversations/cnv_thread_current?projectId=prj_hwpod_workbench"); }); test("hwlab-cli client agent send waits only when --wait is explicit", async () => { @@ -890,6 +897,8 @@ test("hwlab-cli client agent send waits only when --wait is explicit", async () "trc_wait", "--conversation-id", "cnv_wait", + "--session-id", + "ses_wait", "--wait", "--poll-interval-ms", "1", @@ -901,6 +910,9 @@ test("hwlab-cli client agent send waits only when --wait is explicit", async () if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_hwpod_workbench")) { return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_wait", revision: 3, selectedConversationId: "cnv_wait", selectedAgentSessionId: "ses_wait", selectedConversation: { conversationId: "cnv_wait", sessionId: "ses_wait", threadId: "thread-wait" }, workspace: {} } }), { status: 200 }); } + if (String(url).endsWith("/v1/agent/sessions/ses_wait")) { + return new Response(JSON.stringify({ ok: true, session: { sessionId: "ses_wait", conversationId: "cnv_wait", threadId: "thread-wait", status: "idle", providerProfile: "deepseek" } }), { status: 200 }); + } if (String(url).endsWith("/v1/agent/chat")) { return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_wait", turnUrl: "/v1/agent/turns/trc_wait", resultUrl: "/v1/agent/chat/result/trc_wait" }), { status: 202 }); } @@ -916,12 +928,13 @@ test("hwlab-cli client agent send waits only when --wait is explicit", async () }); assert.equal(result.exitCode, 0); - assert.equal(calls[1].body.threadId, "thread-wait"); - assert.equal(calls[2].url, "http://web.test/v1/agent/turns/trc_wait"); - assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_wait"); - assert.equal(Object.hasOwn(calls[3].body, "activeTraceId"), true); - assert.equal(calls[3].body.activeTraceId, null); - assert.equal(calls[3].body.threadId, "thread-wait"); + assert.equal(calls[1].url, "http://web.test/v1/agent/sessions/ses_wait"); + assert.equal(calls[2].body.threadId, "thread-wait"); + assert.equal(calls[3].url, "http://web.test/v1/agent/turns/trc_wait"); + assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace/wsp_wait"); + assert.equal(Object.hasOwn(calls[4].body, "activeTraceId"), true); + assert.equal(calls[4].body.activeTraceId, null); + assert.equal(calls[4].body.threadId, "thread-wait"); assert.equal(result.payload.traceId, "trc_wait"); assert.equal(result.payload.continuation.threadId, "thread-wait"); assert.equal(result.payload.result.body.status, "completed"); @@ -1458,11 +1471,17 @@ test("hwlab-cli agent composer status detects Web unlocked steer mode", async () revision: 9, selectedConversationId: "cnv_composer", selectedAgentSessionId: "ses_composer", + selectedConversation: { + conversationId: "cnv_composer", + sessionId: "ses_composer", + threadId: "thread-composer", + status: "running", + messages: [{ role: "agent", status: "running", traceId: "trc_composer_active", conversationId: "cnv_composer", sessionId: "ses_composer", threadId: "thread-composer" }] + }, workspace: { activeTraceId: "trc_composer_active", - threadId: "thread-composer", - sessionStatus: "running", - messages: [{ role: "agent", status: "running", traceId: "trc_composer_active" }] + threadId: "thread-stale-workspace", + sessionStatus: "running" } } }), { status: 200 }); @@ -1500,11 +1519,17 @@ test("hwlab-cli agent composer status ignores stale workspace activeTrace after selectedConversationId: "cnv_completed", selectedAgentSessionId: "ses_completed", activeTraceId: "trc_stale_running", + selectedConversation: { + conversationId: "cnv_completed", + sessionId: "ses_completed", + threadId: "thread-completed", + status: "completed", + messages: [{ role: "agent", status: "completed", traceId: "trc_completed", conversationId: "cnv_completed", sessionId: "ses_completed", threadId: "thread-completed" }] + }, workspace: { activeTraceId: "trc_stale_running", - threadId: "thread-composer", - sessionStatus: "running", - messages: [{ role: "agent", status: "completed", traceId: "trc_completed", conversationId: "cnv_completed" }] + threadId: "thread-stale-workspace", + sessionStatus: "running" } } }), { status: 200 }) @@ -1545,11 +1570,17 @@ test("hwlab-cli agent composer submit auto routes active Web turn to steer", asy revision: 9, selectedConversationId: "cnv_composer", selectedAgentSessionId: "ses_composer", + selectedConversation: { + conversationId: "cnv_composer", + sessionId: "ses_composer", + threadId: "thread-composer", + status: "running", + messages: [{ role: "agent", status: "running", traceId: "trc_composer_active", conversationId: "cnv_composer", sessionId: "ses_composer", threadId: "thread-composer" }] + }, workspace: { activeTraceId: "trc_composer_active", - threadId: "thread-composer", - sessionStatus: "running", - messages: [{ role: "agent", status: "running", traceId: "trc_composer_active" }] + threadId: "thread-stale-workspace", + sessionStatus: "running" } } }), { status: 200 }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 08c7401e..c6b95e36 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1584,9 +1584,6 @@ async function sessionCurrentCommand(context: any) { workspace, current: sessionSummary(workspace?.selectedConversation) ?? sessionSummary({ conversationId: workspace?.selectedConversationId, - sessionId: workspace?.selectedAgentSessionId, - threadId: workspace?.threadId, - status: workspace?.sessionStatus, lastTraceId: workspace?.activeTraceId, updatedAt: workspace?.updatedAt }) @@ -1741,6 +1738,18 @@ async function fetchAgentSessionForSend(context: any, sessionId: string) { return { response, session }; } +async function fetchAgentConversationForSend(context: any, conversationId: string) { + const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID; + const params = new URLSearchParams(); + if (projectId) params.set("projectId", projectId); + const query = params.toString(); + const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}${query ? `?${query}` : ""}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + if (!isHttpSuccess(response)) return { response, conversation: null, pathName }; + const conversation = response.body?.conversation && typeof response.body.conversation === "object" ? response.body.conversation : null; + return { response, conversation, pathName }; +} + async function agentSessionList(context: any) { const params = new URLSearchParams(); params.set("projectId", text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID); @@ -1842,16 +1851,18 @@ async function agentComposerSubmit(context: any) { function computeCliComposerState(context: any, workspaceState: any) { const state = workspaceState; + const selectedConversation = state?.selectedConversation && typeof state.selectedConversation === "object" ? state.selectedConversation : null; + const selectedSession = selectedConversation?.session && typeof selectedConversation.session === "object" ? selectedConversation.session : null; return computeCodeAgentComposerState({ - workspace: state, - conversationId: text(context.parsed.conversationId) || text(state?.selectedConversationId), - sessionId: text(context.parsed.sessionId) || text(state?.selectedAgentSessionId), - threadId: text(context.parsed.threadId) || text(state?.threadId), - sessionStatus: text(context.parsed.sessionStatus) || text(state?.sessionStatus), + workspace: state ? { activeTraceId: state.activeTraceId, workspaceId: state.workspaceId, revision: state.revision } : null, + conversationId: text(context.parsed.conversationId) || text(selectedConversation?.conversationId) || text(state?.selectedConversationId), + sessionId: text(context.parsed.sessionId) || text(selectedConversation?.sessionId ?? selectedSession?.sessionId), + threadId: text(context.parsed.threadId) || text(selectedConversation?.threadId ?? selectedSession?.threadId), + sessionStatus: text(context.parsed.sessionStatus) || text(selectedConversation?.status ?? selectedSession?.status), targetTraceId: text(context.parsed.traceId ?? context.parsed.targetTraceId) || text(state?.activeTraceId), workspaceId: text(context.parsed.workspaceId) || text(state?.workspaceId), workspaceRevision: numberOption(context.parsed.expectedWorkspaceRevision) ?? state?.revision, - messages: Array.isArray(state?.messages) ? state.messages : undefined + messages: Array.isArray(selectedConversation?.messages) ? selectedConversation.messages : Array.isArray(state?.messages) ? state.messages : undefined }); } @@ -2194,11 +2205,18 @@ async function agentSend(context: any) { } } const explicitSessionConversationId = text(explicitSessionRecord?.conversationId); - const allowWorkspaceSession = !requestedConversationId || requestedConversationId === workspaceConversationId; - const sessionId = explicitSessionId || replay.sessionId || (allowWorkspaceSession ? text(workspaceState?.selectedAgentSessionId) : ""); const conversationId = requestedConversationId || explicitSessionConversationId || workspaceConversationId; - const workspaceSessionId = text(workspaceState?.selectedAgentSessionId); - const workspaceMatchesSelectedSession = workspaceSessionId === sessionId && (!conversationId || workspaceConversationId === conversationId); + let conversationRecord = null; + if (conversationId && !explicitSessionRecord && !explicitSessionId && !replay.sessionId) { + const lookup = await fetchAgentConversationForSend(context, conversationId); + if (!isHttpSuccess(lookup.response)) { + return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", lookup.pathName), traceId, conversationId, replay: replay.summary }); + } + conversationRecord = lookup.conversation; + } + const conversationSessionId = text(conversationRecord?.sessionId ?? conversationRecord?.session?.sessionId); + const conversationMatchesExplicitSession = !explicitSessionId || !conversationSessionId || conversationSessionId === explicitSessionId; + const sessionId = explicitSessionId || replay.sessionId || conversationSessionId; if (!sessionId) { throw cliError("session_required", "client agent send requires an explicit Code Agent session; run client agent session create first", { traceId, @@ -2209,7 +2227,11 @@ async function agentSend(context: any) { ] }); } - const sessionStatus = explicitSessionRecord ? text(explicitSessionRecord.status) : (workspaceSessionId === sessionId ? text(workspaceState?.sessionStatus) : ""); + const sessionStatus = explicitSessionRecord + ? text(explicitSessionRecord.status) + : conversationMatchesExplicitSession + ? text(conversationRecord?.status ?? conversationRecord?.session?.status) + : ""; 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, @@ -2226,7 +2248,10 @@ async function agentSend(context: any) { nextCommands: ["hwlab-cli client agent session create --provider-profile PROFILE"] }); } - const threadId = text(parsed.threadId) || replay.threadId || (explicitSessionRecord ? text(explicitSessionRecord.threadId) : "") || (workspaceMatchesSelectedSession ? text(workspaceState?.threadId) : ""); + const threadId = text(parsed.threadId) + || replay.threadId + || (explicitSessionRecord ? text(explicitSessionRecord.threadId) : "") + || (conversationMatchesExplicitSession ? text(conversationRecord?.threadId ?? conversationRecord?.session?.threadId) : ""); const retryOf = text(parsed.retryOf) || replay.retryOf; const continuation = agentContinuationSummary({ conversationId, @@ -2237,7 +2262,7 @@ async function agentSend(context: any) { }); const requestProviderProfile = text(parsed.providerProfile) || text(explicitSessionRecord?.providerProfile) - || (workspaceMatchesSelectedSession ? text(workspaceState?.providerProfile) : "") + || (conversationMatchesExplicitSession ? text(conversationRecord?.providerProfile ?? conversationRecord?.session?.providerProfile) : "") || "deepseek"; const requestBody = clean({ message, @@ -3350,23 +3375,15 @@ function numberOrZero(value: any) { function workbenchThreadId(workspace: any, workspaceJson: any) { const selectedConversation = workspace?.selectedConversation && typeof workspace.selectedConversation === "object" ? workspace.selectedConversation : {}; - 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 : {}; const topConversationId = text(workspace?.selectedConversationId ?? workspaceJson?.selectedConversationId); - const topSessionId = text(workspace?.selectedAgentSessionId ?? workspaceJson?.selectedAgentSessionId); - const selectedConversationThreadId = selectedConversationMatches(selectedConversation, { conversationId: topConversationId, sessionId: topSessionId }) + const selectedConversationThreadId = selectedConversationMatches(selectedConversation, { conversationId: topConversationId }) ? text(selectedConversation.threadId ?? selectedConversation.session?.threadId) : ""; - const selectedConversationJsonThreadId = selectedConversationMatches(selectedConversationJson, { conversationId: topConversationId, sessionId: topSessionId }) + const selectedConversationJsonThreadId = selectedConversationMatches(selectedConversationJson, { conversationId: topConversationId }) ? text(selectedConversationJson.threadId ?? selectedConversationJson.session?.threadId) : ""; - return selectedConversationThreadId - || text(selectedAgentSession.threadId) - || selectedConversationJsonThreadId - || text(selectedAgentSessionJson.threadId) - || text(workspaceJson?.threadId) - || text(workspace?.threadId); + return selectedConversationThreadId || selectedConversationJsonThreadId; } function workspaceSummaryFromSession(session: any) { diff --git a/tools/src/hwlab-cli/composer-policy.ts b/tools/src/hwlab-cli/composer-policy.ts index 8744f011..db6d16bc 100644 --- a/tools/src/hwlab-cli/composer-policy.ts +++ b/tools/src/hwlab-cli/composer-policy.ts @@ -7,7 +7,7 @@ export function computeCodeAgentComposerState(input = {}) { const latest = latestAgentMessage(input.messages ?? input.chatMessages); const current = input.currentRequest && typeof input.currentRequest === "object" ? input.currentRequest : null; const workspace = input.workspace && typeof input.workspace === "object" ? input.workspace : null; - const explicitStatus = normalizedStatus(input.status ?? input.sessionStatus ?? workspace?.sessionStatus); + const explicitStatus = normalizedStatus(input.status ?? input.sessionStatus); const latestStatus = normalizedStatus(latest?.status); const effectiveStatus = latestStatus || explicitStatus; const traceId = firstNonEmptyString( @@ -26,7 +26,7 @@ export function computeCodeAgentComposerState(input = {}) { ); const mode = active ? "steer" : "turn"; const conversationId = firstNonEmptyString(input.conversationId, current?.conversationId, latest?.conversationId, workspace?.selectedConversationId); - const sessionId = firstNonEmptyString(input.sessionId, current?.sessionId, latest?.sessionId, workspace?.selectedAgentSessionId); + const sessionId = firstNonEmptyString(input.sessionId, current?.sessionId, latest?.sessionId); const sessionUsable = Boolean(sessionId) && !unusableSessionStatus(explicitStatus); const disabledReason = active ? null @@ -50,7 +50,7 @@ export function computeCodeAgentComposerState(input = {}) { targetTraceId: active ? traceId : null, conversationId, sessionId, - threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId, workspace?.threadId), + threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId), workspaceId: firstNonEmptyString(input.workspaceId, workspace?.workspaceId), workspaceRevision: integerOrNull(input.workspaceRevision ?? workspace?.revision), reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(effectiveStatus) ? "terminal-turn" : "idle-turn")