feat: multi-work agent #12
+1
-5
@@ -237,11 +237,7 @@ export class QuotaTracker {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
await this.refresh();
|
||||||
await this.resolveCredential();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCredential(generation = this.generation) {
|
private async resolveCredential(generation = this.generation) {
|
||||||
|
|||||||
+10
-5
@@ -60,9 +60,9 @@ export default Plugin.define({
|
|||||||
setRevision((value) => value + 1);
|
setRevision((value) => value + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const Slot = (props: { sessionID: string }) => {
|
const Slot = (props: { instance: string }) => {
|
||||||
const [status] = createResource(
|
const [status] = createResource(
|
||||||
() => `${props.sessionID}:${revision()}`,
|
() => `${props.instance}:${revision()}`,
|
||||||
() => loadQuotaStatus(() => quota.status({})),
|
() => loadQuotaStatus(() => quota.status({})),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -83,12 +83,17 @@ export default Plugin.define({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = context.ui.slot({
|
const removeHome = context.ui.slot({
|
||||||
|
append: "home.footer.status",
|
||||||
|
render: () => <Slot instance="home" />,
|
||||||
|
});
|
||||||
|
const removeSidebar = context.ui.slot({
|
||||||
append: "sidebar.footer",
|
append: "sidebar.footer",
|
||||||
render: (input) => <Slot sessionID={input.sessionID} />,
|
render: (input) => <Slot instance={input.sessionID} />,
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
remove();
|
removeHome();
|
||||||
|
removeSidebar();
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
clearInterval(retry);
|
clearInterval(retry);
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-9
@@ -329,11 +329,11 @@ test("reads old cached snapshots without session estimates and removes excluded
|
|||||||
]);
|
]);
|
||||||
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||||
expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
|
expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
|
||||||
expect(result.calls()).toBe(0);
|
expect(result.calls()).toBe(1);
|
||||||
await result.tracker.dispose();
|
await result.tracker.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("activates only for OAuth and refreshes quota at request start", async () => {
|
test("activates only for OAuth and refreshes quota at initialization and request start", async () => {
|
||||||
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
const key = fixture({ type: "key", key: "secret" }, [plus]);
|
||||||
await key.tracker.init();
|
await key.tracker.init();
|
||||||
await key.tracker.start("root");
|
await key.tracker.start("root");
|
||||||
@@ -360,10 +360,11 @@ test("activates only for OAuth and refreshes quota at request start", async () =
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
await oauth.tracker.init();
|
await oauth.tracker.init();
|
||||||
await oauth.tracker.start("root");
|
|
||||||
expect(oauth.calls()).toBe(1);
|
expect(oauth.calls()).toBe(1);
|
||||||
|
expect(oauth.tracker.status()?.windows[0]?.remaining).toBe(87.5);
|
||||||
|
await oauth.tracker.start("root");
|
||||||
|
expect(oauth.calls()).toBe(2);
|
||||||
expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one");
|
expect(oauth.headers[0]?.get("chatgpt-account-id")).toBe("account-one");
|
||||||
await oauth.tracker.refresh();
|
|
||||||
const status = oauth.tracker.status();
|
const status = oauth.tracker.status();
|
||||||
expect(status?.windows[0]?.remaining).toBe(82.5);
|
expect(status?.windows[0]?.remaining).toBe(82.5);
|
||||||
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||||
@@ -482,10 +483,10 @@ test("request completion refreshes once across terminal and idle events", async
|
|||||||
await result.tracker.init();
|
await result.tracker.init();
|
||||||
await result.tracker.start("root");
|
await result.tracker.start("root");
|
||||||
await result.tracker.complete("root");
|
await result.tracker.complete("root");
|
||||||
expect(result.tracker.status()?.windows[0]?.remaining).toBe(89);
|
expect(result.tracker.status()?.windows[0]?.remaining).toBe(70);
|
||||||
await result.tracker.complete("root");
|
await result.tracker.complete("root");
|
||||||
expect(result.calls()).toBe(2);
|
expect(result.calls()).toBe(3);
|
||||||
expect(result.tracker.status()?.windows[0]?.remaining).toBe(89);
|
expect(result.tracker.status()?.windows[0]?.remaining).toBe(70);
|
||||||
await result.tracker.dispose();
|
await result.tracker.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -513,8 +514,7 @@ test("an auth switch detaches an in-flight old-account response and immediately
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await result.tracker.init();
|
const old = result.tracker.init();
|
||||||
const old = result.tracker.start("root");
|
|
||||||
while (result.calls() === 0) await Bun.sleep(1);
|
while (result.calls() === 0) await Bun.sleep(1);
|
||||||
result.switch({
|
result.switch({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
|
|||||||
+31
-9
@@ -5,7 +5,8 @@ import { createSignal } from "solid-js";
|
|||||||
import { build } from "../scripts/build.js";
|
import { build } from "../scripts/build.js";
|
||||||
import type { QuotaStatus } from "../src/quota.js";
|
import type { QuotaStatus } from "../src/quota.js";
|
||||||
|
|
||||||
type SlotRender = (input: { readonly sessionID: string }) => JSX.Element;
|
type SidebarSlotRender = (input: { readonly sessionID: string }) => JSX.Element;
|
||||||
|
type HomeSlotRender = (input: Record<string, never>) => JSX.Element;
|
||||||
|
|
||||||
const status = (plan: string, remaining: number): QuotaStatus => ({
|
const status = (plan: string, remaining: number): QuotaStatus => ({
|
||||||
plan,
|
plan,
|
||||||
@@ -30,9 +31,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
|||||||
);
|
);
|
||||||
const { default: plugin } = tui;
|
const { default: plugin } = tui;
|
||||||
const pending = Promise.withResolvers<QuotaStatus | null>();
|
const pending = Promise.withResolvers<QuotaStatus | null>();
|
||||||
const responses: Array<() => Promise<QuotaStatus | null>> = [() => pending.promise];
|
const responses: Array<() => Promise<QuotaStatus | null>> = [
|
||||||
|
async () => status("home", 90),
|
||||||
|
() => pending.promise,
|
||||||
|
];
|
||||||
let statusCalls = 0;
|
let statusCalls = 0;
|
||||||
let slotRender: SlotRender | undefined;
|
let sidebarRender: SidebarSlotRender | undefined;
|
||||||
|
let homeRender: HomeSlotRender | undefined;
|
||||||
let notifyUpdated: (() => void) | undefined;
|
let notifyUpdated: (() => void) | undefined;
|
||||||
|
|
||||||
const quota = {
|
const quota = {
|
||||||
@@ -52,8 +57,17 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
|||||||
const contextFixture = {
|
const contextFixture = {
|
||||||
client: { rpc: () => quota },
|
client: { rpc: () => quota },
|
||||||
ui: {
|
ui: {
|
||||||
slot(claim: { render: SlotRender }) {
|
slot(claim: {
|
||||||
slotRender = claim.render;
|
append: "home.footer.status" | "sidebar.footer";
|
||||||
|
render: HomeSlotRender | SidebarSlotRender;
|
||||||
|
}) {
|
||||||
|
if (claim.append === "home.footer.status") {
|
||||||
|
// SAFETY: The home slot renderer receives the empty home slot input.
|
||||||
|
homeRender = claim.render as HomeSlotRender;
|
||||||
|
} else {
|
||||||
|
// SAFETY: The only other fixture path is the session-aware sidebar slot.
|
||||||
|
sidebarRender = claim.render as SidebarSlotRender;
|
||||||
|
}
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,10 +77,18 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
|||||||
// members implemented by this focused rendering fixture.
|
// members implemented by this focused rendering fixture.
|
||||||
const context = Object.assign({} as Plugin.Context, contextFixture);
|
const context = Object.assign({} as Plugin.Context, contextFixture);
|
||||||
const cleanup = await plugin.setup(context);
|
const cleanup = await plugin.setup(context);
|
||||||
if (!slotRender || !notifyUpdated) throw new Error("Plugin did not register its slot");
|
if (!sidebarRender || !homeRender || !notifyUpdated)
|
||||||
|
throw new Error("Plugin did not register its slots");
|
||||||
|
|
||||||
|
const renderHome = homeRender;
|
||||||
|
const home = await testRender(() => renderHome({}), { width: 70, height: 5 });
|
||||||
|
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
||||||
|
expect(home.captureCharFrame()).toContain("5h: 90% · due");
|
||||||
|
expect(statusCalls).toBe(1);
|
||||||
|
home.renderer.destroy();
|
||||||
|
|
||||||
const [sessionID, setSessionID] = createSignal("session-one");
|
const [sessionID, setSessionID] = createSignal("session-one");
|
||||||
const renderSlot = slotRender;
|
const renderSlot = sidebarRender;
|
||||||
const setup = await testRender(
|
const setup = await testRender(
|
||||||
() =>
|
() =>
|
||||||
renderSlot({
|
renderSlot({
|
||||||
@@ -94,13 +116,13 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
|||||||
throw new Error("disconnected");
|
throw new Error("disconnected");
|
||||||
});
|
});
|
||||||
notifyUpdated();
|
notifyUpdated();
|
||||||
await setup.waitFor(() => statusCalls === 3);
|
await setup.waitFor(() => statusCalls === 4);
|
||||||
expect(setup.captureCharFrame()).not.toContain("ChatGPT quota");
|
expect(setup.captureCharFrame()).not.toContain("ChatGPT quota");
|
||||||
|
|
||||||
responses.push(async () => status("pro", 42));
|
responses.push(async () => status("pro", 42));
|
||||||
setSessionID("session-two");
|
setSessionID("session-two");
|
||||||
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)"));
|
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)"));
|
||||||
expect(statusCalls).toBe(4);
|
expect(statusCalls).toBe(5);
|
||||||
} finally {
|
} finally {
|
||||||
setup.renderer.destroy();
|
setup.renderer.destroy();
|
||||||
if (cleanup) await cleanup();
|
if (cleanup) await cleanup();
|
||||||
|
|||||||
Reference in New Issue
Block a user