feat(agent): add credential refresh and unify learning tools
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
CredentialRefreshCoordinator,
|
||||
CredentialRefreshError,
|
||||
runWithCredentialRefresh,
|
||||
} from "../../src/auth/credentialRefresh.js";
|
||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
const context = (overrides: Partial<RuntimeSessionContext> = {}) => ({
|
||||
accessToken: "old-token",
|
||||
actorKey: "user-1",
|
||||
clientSessionId: "client-1",
|
||||
projectId: "project-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "session-1",
|
||||
traceId: "trace-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("CredentialRefreshCoordinator", () => {
|
||||
test("deduplicates concurrent refreshes for one session", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator();
|
||||
const requestIds: string[] = [];
|
||||
coordinator.subscribe("session-1", (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
requestIds.push(event.requestId);
|
||||
}
|
||||
});
|
||||
const expired = context({ tokenExpiresAt: new Date(0).toISOString() });
|
||||
const execute = async (active: RuntimeSessionContext) => ({
|
||||
status: 200,
|
||||
token: active.accessToken,
|
||||
});
|
||||
|
||||
const first = runWithCredentialRefresh(coordinator, expired, execute);
|
||||
const second = runWithCredentialRefresh(coordinator, expired, execute);
|
||||
await Promise.resolve();
|
||||
expect(requestIds).toHaveLength(1);
|
||||
expect(coordinator.getPendingEvent("session-1")).toMatchObject({
|
||||
type: "credential_refresh_required",
|
||||
requestId: requestIds[0],
|
||||
reason: "access_token_expired",
|
||||
});
|
||||
coordinator.resolve(
|
||||
"session-1",
|
||||
requestIds[0]!,
|
||||
context({ accessToken: "fresh-token" }),
|
||||
);
|
||||
|
||||
expect(await first).toEqual({ status: 200, token: "fresh-token" });
|
||||
expect(await second).toEqual({ status: 200, token: "fresh-token" });
|
||||
expect(coordinator.getPendingEvent("session-1")).toBeNull();
|
||||
});
|
||||
|
||||
test("retries one time on 401 and never refreshes a 403", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator();
|
||||
let requestId = "";
|
||||
coordinator.subscribe("session-1", (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
requestId = event.requestId;
|
||||
}
|
||||
});
|
||||
let attempts = 0;
|
||||
const resultPromise = runWithCredentialRefresh(
|
||||
coordinator,
|
||||
context(),
|
||||
async () => ({ status: ++attempts === 1 ? 401 : 401 }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
coordinator.resolve("session-1", requestId, context({ accessToken: "fresh-token" }));
|
||||
expect((await resultPromise).status).toBe(401);
|
||||
expect(attempts).toBe(2);
|
||||
|
||||
requestId = "";
|
||||
expect(
|
||||
(await runWithCredentialRefresh(coordinator, context(), async () => ({ status: 403 })))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(requestId).toBe("");
|
||||
});
|
||||
|
||||
test("fails explicitly when no event stream can refresh credentials", async () => {
|
||||
await expect(
|
||||
runWithCredentialRefresh(
|
||||
new CredentialRefreshCoordinator(),
|
||||
context({ tokenExpiresAt: new Date(0).toISOString() }),
|
||||
async () => ({ status: 200 }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(CredentialRefreshError);
|
||||
});
|
||||
|
||||
test("reports run cancellation separately from authentication failure", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator();
|
||||
coordinator.subscribe("session-1", () => undefined);
|
||||
const pending = coordinator.request("session-1", "access_token_rejected");
|
||||
coordinator.cancelSession("session-1");
|
||||
await expect(pending).rejects.toMatchObject({ code: "cancelled" });
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe("Agent REST OpenAPI", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(operationCount).toBe(13);
|
||||
expect(operationCount).toBe(14);
|
||||
});
|
||||
|
||||
test("models runs as session subresources", () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
executeMemoryManager,
|
||||
executeSkillManager,
|
||||
} from "../../src/learning/toolManagers.js";
|
||||
import { MemoryStore } from "../../src/memory/store.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
removeRuntimeSessionContext,
|
||||
setRuntimeSessionContext,
|
||||
type RuntimeSessionContext,
|
||||
} from "../../src/runtime/sessionContext.js";
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
|
||||
describe("main-process learning tool managers", () => {
|
||||
let tempDir: string;
|
||||
let memoryStore: MemoryStore;
|
||||
let skillStore: SkillStore;
|
||||
let context: RuntimeSessionContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-learning-tools-"));
|
||||
memoryStore = new MemoryStore(
|
||||
join(tempDir, "memory"),
|
||||
join(tempDir, "backup", "memory"),
|
||||
);
|
||||
skillStore = new SkillStore(
|
||||
join(tempDir, "skills"),
|
||||
join(tempDir, "backup", "skills"),
|
||||
);
|
||||
await memoryStore.initialize();
|
||||
context = {
|
||||
actorKey: "actor-1",
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "client-session-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "session-1",
|
||||
traceId: "trace-1",
|
||||
};
|
||||
setRuntimeSessionContext(context);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
removeRuntimeSessionContext(context.sessionId);
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("enforces list-before-add using the canonical runtime context", async () => {
|
||||
const rejected = await executeMemoryManager(memoryStore, context, {
|
||||
action: "add",
|
||||
content: "用户偏好查看压力单位为 MPa",
|
||||
scope: "user",
|
||||
});
|
||||
expect(rejected.decision).toBe("rejected");
|
||||
|
||||
await executeMemoryManager(memoryStore, context, {
|
||||
action: "list",
|
||||
scope: "user",
|
||||
});
|
||||
const refreshedContext = getRuntimeSessionContext(context.sessionId)!;
|
||||
const accepted = await executeMemoryManager(memoryStore, refreshedContext, {
|
||||
action: "add",
|
||||
content: "用户偏好查看压力单位为 MPa",
|
||||
scope: "user",
|
||||
});
|
||||
expect(accepted.decision).toBe("accepted");
|
||||
expect(await memoryStore.list("user", context.actorKey)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("writes and removes skills through the shared store", async () => {
|
||||
const content = [
|
||||
"---",
|
||||
"name: pressure-review",
|
||||
"description: Pressure review workflow.",
|
||||
"---",
|
||||
"",
|
||||
"# Pressure Review",
|
||||
].join("\n");
|
||||
const written = await executeSkillManager(skillStore, context, {
|
||||
action: "write_skill",
|
||||
content,
|
||||
skill_path: "workflow/pressure-review",
|
||||
});
|
||||
expect(written.decision).toBe("accepted");
|
||||
expect("target" in written).toBe(true);
|
||||
if (!("target" in written)) throw new Error("write returned no target");
|
||||
await expect(readFile(written.target, "utf8")).resolves.toContain(
|
||||
"# Pressure Review\n",
|
||||
);
|
||||
|
||||
const removed = await executeSkillManager(skillStore, context, {
|
||||
action: "remove_skill",
|
||||
skill_path: "workflow/pressure-review",
|
||||
});
|
||||
expect(removed.decision).toBe("accepted");
|
||||
expect("target" in removed).toBe(true);
|
||||
if (!("target" in removed)) throw new Error("remove returned no target");
|
||||
await expect(readFile(removed.target, "utf8")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
|
||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
|
||||
describe("skill_manager tool", () => {
|
||||
let tempDir: string;
|
||||
let skillStore: SkillStore;
|
||||
let context: RuntimeSessionContext;
|
||||
|
||||
const toolContext = {
|
||||
abort: new AbortController().signal,
|
||||
agent: "test",
|
||||
ask: (() => undefined) as never,
|
||||
directory: "",
|
||||
messageID: "message-1",
|
||||
metadata: () => undefined,
|
||||
sessionID: "session-1",
|
||||
worktree: "",
|
||||
};
|
||||
|
||||
const skillDocument = (body: string) =>
|
||||
[
|
||||
"---",
|
||||
"name: pressure-review",
|
||||
"description: Pressure review workflow.",
|
||||
"---",
|
||||
"",
|
||||
body,
|
||||
].join("\n");
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
|
||||
skillStore = new SkillStore(
|
||||
join(tempDir, "skills"),
|
||||
join(tempDir, "backup", "skills"),
|
||||
);
|
||||
context = {
|
||||
actorKey: "actor-1",
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "client-session-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "session-1",
|
||||
traceId: "trace-1",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("dispatches skill-level write, overwrite, and remove actions", async () => {
|
||||
const tool = createSkillManagerTool(
|
||||
skillStore,
|
||||
{ read: () => context },
|
||||
Promise.resolve(),
|
||||
);
|
||||
|
||||
const writeResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "write_skill",
|
||||
content: skillDocument("# Pressure Review"),
|
||||
reason: "verified reusable workflow",
|
||||
skill_path: "workflow/pressure-review",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
expect(writeResult.decision).toBe("accepted");
|
||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
||||
"# Pressure Review\n",
|
||||
);
|
||||
|
||||
const updateResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "write_skill",
|
||||
content: skillDocument("# Updated Pressure Review"),
|
||||
reason: "verified reusable workflow overwrite",
|
||||
skill_path: "workflow/pressure-review",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
expect(updateResult.decision).toBe("accepted");
|
||||
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
|
||||
"# Updated Pressure Review\n",
|
||||
);
|
||||
|
||||
const removeResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "remove_skill",
|
||||
reason: "workflow is obsolete",
|
||||
skill_path: "workflow/pressure-review",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
expect(removeResult.decision).toBe("accepted");
|
||||
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("writes the root skills index through the reserved alias", async () => {
|
||||
const tool = createSkillManagerTool(
|
||||
skillStore,
|
||||
{ read: () => context },
|
||||
Promise.resolve(),
|
||||
);
|
||||
|
||||
const writeResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "write_skill",
|
||||
content: [
|
||||
"---",
|
||||
"name: skills",
|
||||
"description: TJWater Skills root index.",
|
||||
"---",
|
||||
"",
|
||||
"# TJWater Skills",
|
||||
].join("\n"),
|
||||
reason: "refresh root skills index",
|
||||
skill_path: "__root__",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
|
||||
expect(writeResult.decision).toBe("accepted");
|
||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
||||
"# TJWater Skills\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ describe("Agent public REST router", () => {
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
|
||||
const runtimeOperations = layers
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
readBridgedRuntimeSessionContext,
|
||||
serializeRuntimeSessionContext,
|
||||
} from "../../src/runtime/internalSessionContextBridge.js";
|
||||
import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
describe("readBridgedRuntimeSessionContext", () => {
|
||||
const sessionId = "remote-opencode-session";
|
||||
|
||||
afterEach(() => {
|
||||
removeRuntimeSessionContext(sessionId);
|
||||
});
|
||||
|
||||
it("hydrates a child-process context through the authenticated internal bridge", async () => {
|
||||
const calls: Array<{ input: string; init?: RequestInit }> = [];
|
||||
const context = await readBridgedRuntimeSessionContext(sessionId, {
|
||||
baseUrl: "http://127.0.0.1:8787",
|
||||
internalToken: "internal-secret",
|
||||
fetchImpl: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return Response.json({
|
||||
actor_key: "actor-1",
|
||||
allow_learning_write: true,
|
||||
client_session_id: "client-session-1",
|
||||
project_key: "project-1",
|
||||
session_id: sessionId,
|
||||
trace_id: "trace-1",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
actorKey: "actor-1",
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "client-session-1",
|
||||
projectKey: "project-1",
|
||||
sessionId,
|
||||
traceId: "trace-1",
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.input).toBe(
|
||||
"http://127.0.0.1:8787/internal/tools/session-context",
|
||||
);
|
||||
expect(calls[0]?.init?.headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": "internal-secret",
|
||||
});
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
|
||||
session_id: sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose backend credentials to the opencode child process", () => {
|
||||
const context = {
|
||||
accessToken: "backend-secret",
|
||||
actorKey: "actor-1",
|
||||
clientSessionId: "client-session-1",
|
||||
network: "network-1",
|
||||
projectId: "project-id-1",
|
||||
projectKey: "project-key-1",
|
||||
sessionId,
|
||||
traceId: "trace-1",
|
||||
};
|
||||
expect(context).toHaveProperty("accessToken", "backend-secret");
|
||||
|
||||
const payload = serializeRuntimeSessionContext(context);
|
||||
|
||||
expect(payload).toEqual({
|
||||
actor_key: "actor-1",
|
||||
allow_learning_write: undefined,
|
||||
client_session_id: "client-session-1",
|
||||
memory_list_read_scopes: undefined,
|
||||
project_key: "project-key-1",
|
||||
session_id: sessionId,
|
||||
trace_id: "trace-1",
|
||||
});
|
||||
expect(payload).not.toHaveProperty("access_token");
|
||||
expect(payload).not.toHaveProperty("project_id");
|
||||
expect(payload).not.toHaveProperty("network");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user