fix: remove workspace session fallback from hwlab cli

This commit is contained in:
lyon
2026-06-17 15:53:44 +08:00
parent 36dfceb52c
commit 78a6b82751
3 changed files with 116 additions and 68 deletions
+69 -38
View File
@@ -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 });
+44 -27
View File
@@ -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) {
+3 -3
View File
@@ -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")