Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/console/src/api/__tests__/agent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiGet, apiPost, apiPut } from "@/utils/request";
import { agentApi } from "@/api/agent";

vi.mock("@/utils/request", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
}));

describe("agentApi", () => {
beforeEach(() => vi.clearAllMocks());

it("uses the authenticated Agent workflow endpoints and explicit confirmation payload", async () => {
const thread = { threadId: "thread-1" };
vi.mocked(apiPost).mockResolvedValue(thread);
vi.mocked(apiPut).mockResolvedValue(thread);
vi.mocked(apiGet).mockResolvedValue(thread);

await agentApi.createThread("submission-1", "review this");
expect(apiPost).toHaveBeenCalledWith("/agent/threads", {
sourceSubmissionId: "submission-1",
question: "review this",
});
await agentApi.confirm("thread-1", {
draftVersion: 2,
title: "Title",
content: "Content",
facts: [],
hypotheses: [],
citations: [],
citationChecks: [],
sampleScope: null,
}, "digest");
expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/confirm", {
draftVersion: 2,
paramsDigest: "digest",
confirm: true,
});
await agentApi.save("thread-1", "confirmation-1");
expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/save", {
confirmationId: "confirmation-1",
});
await agentApi.recover("thread-1");
expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/recover", { retry: false });
await agentApi.cancel("thread-1");
expect(apiPost).toHaveBeenLastCalledWith("/agent/threads/thread-1/cancel", {});
await agentApi.updateDraft("thread-1", { draftVersion: 2, title: "Title", content: "Content" });
expect(apiPut).toHaveBeenCalledWith("/agent/threads/thread-1/draft", {
draftVersion: 2,
title: "Title",
content: "Content",
});
});

it("encodes thread IDs for reads and event streams", async () => {
vi.mocked(apiGet).mockResolvedValue({});
await agentApi.getThread("thread/with space");
expect(apiGet).toHaveBeenCalledWith("/agent/threads/thread%2Fwith%20space");
expect(agentApi.eventStreamUrl("thread/with space")).toBe(
"/api/agent/threads/thread%2Fwith%20space/events/stream?after=0",
);
});
});
109 changes: 109 additions & 0 deletions apps/console/src/api/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { apiGet, apiPost, apiPut } from "@/utils/request";

export type AgentThreadStatus =
| "awaiting_confirmation"
| "analyzing"
| "confirmed"
| "saving"
| "saved"
| "unknown"
| "failed"
| "cancelled";

export interface AgentCitation {
claim: string;
chunk_id: string;
doc_id: string;
version: string;
source_path: string;
source_position: string;
access_scope: string;
sample_kind: string;
source_trust: string;
text: string;
}

export interface AgentWorkflowEvent {
seq: number;
kind: string;
detail: Record<string, unknown>;
createdAt: number;
}

export interface AgentThread {
threadId: string;
runId: string;
status: AgentThreadStatus;
sourceSubmissionId: string;
question: string;
draft: {
draftVersion: number;
title: string;
content: string;
facts: unknown[];
hypotheses: unknown[];
citations: AgentCitation[];
citationChecks: Array<Record<string, unknown>>;
sampleScope: string | null;
model_answer?: string;
};
paramsDigest: string;
analysis: {
answer?: string;
trace?: Array<{ tool_name?: string; failed?: boolean }>;
citation_checks?: Array<Record<string, unknown>>;
} | null;
confirmation: {
id?: string;
action?: string;
draftVersion?: number;
paramsDigest?: string;
expiresAt?: number;
} | null;
receipt: {
planId: string | null;
cancelRequested: boolean;
failureReason: string | null;
};
}

const threadPath = (threadId: string) =>
`/agent/threads/${encodeURIComponent(threadId)}`;

export const agentApi = {
createThread(sourceSubmissionId: string, question: string) {
return apiPost<AgentThread>("/agent/threads", { sourceSubmissionId, question });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable retries when creating a thread

When the server commits the create but its response is lost or replaced by a 5xx, the shared HTTP client automatically retries this POST up to twice. The create endpoint generates a fresh thread ID and business key on every invocation and does not deduplicate by request ID, so one click can leave multiple owner threads while the UI tracks only the response from the last successful attempt. Pass { retry: 0 } for creation or add a server-recognized idempotency key.

Useful? React with 👍 / 👎.

},
getThread(threadId: string) {
return apiGet<AgentThread>(threadPath(threadId));
},
analyze(threadId: string) {
return apiPost<AgentThread>(`${threadPath(threadId)}/analyze`, {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable retries for the stateful analysis request

If analysis completes server-side but its response is lost or hits the shared 30-second client timeout, this call is automatically retried up to twice by packages/http-client/src/index.ts:399-428 because no { retry: 0 } is supplied. The endpoint accepts the resulting awaiting_confirmation state and starts a new run, potentially repeating billed model calls and appending another analysis to the draft; disable transport retries here and rely on the existing canonical refresh/polling recovery.

Useful? React with 👍 / 👎.

},
updateDraft(
threadId: string,
draft: { draftVersion: number; title: string; content: string },
) {
return apiPut<AgentThread>(`${threadPath(threadId)}/draft`, draft);
},
confirm(threadId: string, draft: AgentThread["draft"], paramsDigest: string) {
return apiPost<AgentThread>(`${threadPath(threadId)}/confirm`, {
draftVersion: draft.draftVersion,
paramsDigest,
confirm: true,
});
},
save(threadId: string, confirmationId: string) {
return apiPost<AgentThread>(`${threadPath(threadId)}/save`, { confirmationId });
},
recover(threadId: string) {
return apiPost<AgentThread>(`${threadPath(threadId)}/recover`, { retry: false });
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide an explicit retry path after reconciliation

When a save reaches unknown and the safe readback confirms that no Java record exists, this method always sends retry: false, so the thread remains unknown; the only visible recovery button invokes the same check again. The backend contract supports a later explicit retry: true under the original idempotency key and confirmation guards, but this API shape makes that recovery path unreachable from the demo even after a transient pre-dispatch failure has cleared.

AGENTS.md reference: AGENTS.md:L39-L39

Useful? React with 👍 / 👎.

},
cancel(threadId: string) {
return apiPost<AgentThread>(`${threadPath(threadId)}/cancel`, {});
},
eventStreamUrl(threadId: string) {
const base = (import.meta.env.VITE_API_BASE_URL || "/api").replace(/\/$/, "");
return `${base}${threadPath(threadId)}/events/stream?after=0`;
},
};
Loading
Loading