merge(assistant): runs lane

Claude-Session: https://claude.ai/code/session_017PUuNbanUSUUbZ5dcZBYmM
This commit is contained in:
Harsh Mahajan
2026-11-01 18:10:12 +05:30
54 changed files with 4221 additions and 84 deletions
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computed, defineComponent, h } from "vue";
import { runEscapeLadder } from "@/composables/assistant";
@@ -157,7 +157,7 @@ describe("AssistantApprovalCard", () => {
);
expect(wrapper.get('[data-test="assistant-approval-heading"]').text()).toBe("Delete 1 alert");
const countdown = wrapper.get('[data-test="assistant-approval-countdown"]');
expect(countdown.text()).toBe("Expires in 0:25");
expect(countdown.text()).toBe("Expires soon · 0:25");
expect(countdown.attributes("aria-hidden")).toBe("true");
expect(wrapper.get('[data-test="assistant-approval-expiry"]').attributes("aria-live")).toBe(
"off",
@@ -612,4 +612,91 @@ describe("AssistantApprovalCard", () => {
const details = wrapper.get('[data-test="assistant-approval-details"]');
expect(details.text()).toContain("Details");
});
describe("timing (`30` §7.3)", () => {
const PERMISSION_FRAME = { kind: "permission", tool: "o2_search", args: {} };
const inMs = (ms: number) => cardOf(PERMISSION_FRAME, { expiresAt: Date.now() + ms });
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("drops More time when the user's budget is spent, and says so", () => {
const spent = mountCard({ ...inMs(300_000), userExtendsLeft: 0 }, { extendable: true });
expect(spent.find('[data-test="assistant-approval-extend"]').exists()).toBe(false);
expect(spent.get('[data-test="assistant-approval-last-extension"]').text()).toBe(
"No more time can be added.",
);
const left = mountCard({ ...inMs(300_000), userExtendsLeft: 1 }, { extendable: true });
expect(left.find('[data-test="assistant-approval-extend"]').exists()).toBe(true);
expect(left.find('[data-test="assistant-approval-last-extension"]').exists()).toBe(false);
});
it("warns at 60 s and 20 s, once each, and an extension starts over", async () => {
const wrapper = mountCard(inMs(90_000));
await flushPromises();
const warnings = () =>
(wrapper.emitted("announce") ?? [])
.map(([text]) => String(text))
.filter((text) => text.includes("Approval expires in"));
await vi.advanceTimersByTimeAsync(30_000);
expect(warnings()).toHaveLength(1);
expect(warnings()[0]).toContain("More time is on the card.");
await vi.advanceTimersByTimeAsync(40_000);
expect(warnings()).toHaveLength(2);
await vi.advanceTimersByTimeAsync(5_000);
expect(warnings()).toHaveLength(2);
await wrapper.setProps({ card: inMs(70_000) });
await flushPromises();
expect((wrapper.emitted("announce") ?? []).map(([text]) => String(text)).join()).toContain(
"More time added.",
);
await vi.advanceTimersByTimeAsync(10_000);
expect(warnings()).toHaveLength(3);
});
it("gives no warning for a window it mounted inside: the mount line said the time", async () => {
const wrapper = mountCard(inMs(50_000));
await flushPromises();
await vi.advanceTimersByTimeAsync(5_000);
const spoken = (wrapper.emitted("announce") ?? []).map(([text]) => String(text));
expect(spoken.filter((text) => text.includes("Approval expires in"))).toEqual([]);
});
it("reads Expires soon from the 60 s warning on", async () => {
const wrapper = mountCard(inMs(90_000));
const countdown = () => wrapper.get('[data-test="assistant-approval-countdown"]').text();
expect(countdown()).toBe("Expires in 1:30");
await vi.advanceTimersByTimeAsync(31_000);
expect(countdown()).toBe("Expires soon · 0:59");
});
});
describe("gated, on a server without split budgets (PO A14, B96)", () => {
const frame = { kind: "permission", tool: "o2_search", args: {} };
it("keeps Deny and Reply, and puts the reason where Approve was", () => {
const wrapper = mountCard(cardOf(frame, { expiresAt: Date.now() + 300_000 }), {
gated: true,
extendable: true,
});
expect(wrapper.find('[data-test="assistant-approval-deny"]').exists()).toBe(true);
expect(wrapper.find('[data-test="assistant-approval-reply"]').exists()).toBe(true);
expect(wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false);
expect(wrapper.find('[data-test="assistant-approval-extend"]').exists()).toBe(false);
expect(wrapper.get('[data-test="assistant-approval-gated"]').text()).toContain(
"Approving needs a newer O2 Assistant agent.",
);
});
it("decides the gate when the card mounts, so an upgrade never changes a card being read", async () => {
const wrapper = mountCard(cardOf(frame), { gated: true });
await wrapper.setProps({ gated: false });
expect(wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false);
});
});
});
@@ -57,7 +57,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="tabular-nums"
data-test="assistant-approval-countdown"
>
{{ t("assistant.approval.expiresIn", { time: clockText }) }}
{{
t(expiringSoon ? "assistant.approval.expiringSoon" : "assistant.approval.expiresIn", {
time: clockText,
})
}}
</OText>
<OText
v-if="remainingMs !== null && !sending && extendsSpent"
variant="meta"
as="span"
data-test="assistant-approval-last-extension"
>
{{ t("assistant.approval.lastExtension") }}
</OText>
</div>
<OText v-if="card.askedIn === 'other_tab'" variant="meta" as="p">
@@ -184,9 +196,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- The buttons stay while the decision is on its way, so focus stays where it was -->
<div class="flex min-w-0 flex-col gap-2">
<OText v-if="gated" variant="meta" as="p" data-test="assistant-approval-gated">
{{ t("assistant.approval.gateTiming", { product }) }}
</OText>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<OButton
v-if="mdUp"
v-if="mdUp || gated"
ref="denyButton"
variant="secondary"
size="sm"
@@ -211,7 +226,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("assistant.approval.reply") }}
</OButton>
<OButton
v-if="planned && card.editable"
v-if="planned && card.editable && !gated"
variant="ghost"
size="sm"
:disabled="sending"
@@ -223,7 +238,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("assistant.approval.edit") }}
</OButton>
<OButton
v-if="editor"
v-if="editor && !gated"
variant="ghost"
size="sm"
:disabled="sending"
@@ -247,7 +262,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("assistant.approval.moreTime") }}
</OButton>
<OButton
v-if="mdUp"
v-if="mdUp && !gated"
:variant="card.destructive ? 'destructive' : 'primary'"
size="sm"
:disabled="sending || nothingSelected"
@@ -262,7 +277,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</OButton>
</div>
<div v-if="!mdUp" class="flex min-w-0 gap-2">
<div v-if="!mdUp && !gated" class="flex min-w-0 gap-2">
<OButton
ref="denyButton"
variant="secondary"
@@ -360,12 +375,14 @@ import {
assistantKeydownAt,
registerEscapeRung,
useAssistantIdleFocus,
useAssistantName,
type ApprovalDecision,
type ApprovalScope,
} from "@/composables/assistant";
import {
approvalClock,
approvalDiff,
approvalDuration,
approvalEditor,
approvalEntity,
approvalHeading,
@@ -397,9 +414,7 @@ const FIRST_TARGETS = 5;
/** An id the name could not be resolved for is shown this short (D3-n). */
const SHORT_ID_CHARS = 8;
/** A card this long gets one spoken warning at a minute left (WCAG 2.2.1). */
const LONG_CARD_MS = 120_000;
/** From here until a decision the tag line reads "Expires soon" (`30` §7.3). */
const WARN_AT_MS = 60_000;
/** Past this many items the impact says it is a large batch, without blocking it. */
@@ -408,6 +423,9 @@ const LARGE_BATCH = 20;
/** A key pressed this close before the card mounted may land its keyup on the card; that one activation is ignored. */
const CARRIED_KEY_MS = 1_000;
/** The two warnings of every window, longest first (`30` §7.3). */
const WARN_ATS_MS = [60_000, 20_000];
const props = withDefaults(
defineProps<{
card: ApprovalCardView;
@@ -416,11 +434,13 @@ const props = withDefaults(
sending?: boolean;
/** The server lets the card ask for more time. */
extendable?: boolean;
/** The server has no split budgets: Deny and Reply only (PO A14, B96). */
gated?: boolean;
/** The org's display name; shown only when the user can switch between orgs. */
orgName?: string;
multiOrg?: boolean;
}>(),
{ org: "", sending: false, extendable: false, orgName: "", multiOrg: false },
{ org: "", sending: false, extendable: false, gated: false, orgName: "", multiOrg: false },
);
const emit = defineEmits<{
@@ -432,6 +452,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18nTyped();
const { product } = useAssistantName();
const router = useRouter();
const { mdUp } = useBreakpoint();
const idle = useAssistantIdleFocus();
@@ -456,9 +477,11 @@ const selected = ref<string[]>(props.card.targets.map((target) => target.id ?? t
const mountedAt = ref(Number.POSITIVE_INFINITY);
let keydownAt = Number.NEGATIVE_INFINITY;
let carriedKey = false;
let warned = false;
// A card that mounts inside a warning's window said its time in the mount announcement.
let warned = new Set(WARN_ATS_MS.filter((at) => initialLeft() <= at));
let unmounted = false;
const initialMs = typeof props.card.expiresAt === "number" ? props.card.expiresAt - Date.now() : 0;
// Decided when the card mounts, so a server upgrade never changes a card under the reader (`30` §7.3).
const gated = props.gated;
let timer: ReturnType<typeof setInterval> | null = null;
const offs: (() => void)[] = [];
@@ -477,9 +500,16 @@ const remainingMs = computed(() =>
);
const clockText = computed(() => raw(approvalClock(remainingMs.value ?? 0)));
// From mount, so the reader never has to race the countdown for it (PO A11, A23).
const extendsSpent = computed(() => props.card.userExtendsLeft === 0);
const moreTime = computed(
() => props.extendable && remainingMs.value !== null && remainingMs.value > 0,
() =>
props.extendable &&
!gated &&
!extendsSpent.value &&
remainingMs.value !== null &&
remainingMs.value > 0,
);
const expiringSoon = computed(() => remainingMs.value !== null && remainingMs.value <= WARN_AT_MS);
const targets = computed(() =>
props.card.targets.map((target) => ({
@@ -594,6 +624,10 @@ const describedBy = computed(
const argsText = computed(() => JSON.stringify(props.card.args ?? {}, null, 2));
function initialLeft(): number {
return typeof props.card.expiresAt === "number" ? props.card.expiresAt - Date.now() : Infinity;
}
function onKeydown(): void {
keydownAt = Date.now();
}
@@ -611,7 +645,7 @@ function deny(reason: "deny" | "escape"): void {
}
function approve(): void {
if (nothingSelected.value) return;
if (nothingSelected.value || gated) return;
decide({
approved: true,
scope: canAlwaysAllow(props.card) ? scope.value : "once",
@@ -656,19 +690,23 @@ function announce(moved: boolean): void {
}
function duration(ms: number): I18nText {
const seconds = Math.ceil(ms / 1000);
if (seconds < 120) return t("assistant.approval.seconds", { count: seconds }, seconds);
const minutes = Math.round(seconds / 60);
return t("assistant.approval.minutes", { count: minutes }, minutes);
return approvalDuration(ms, t);
}
// Each warning speaks once per window; an extension starts the window over.
function tick(): void {
now.value = Date.now();
const ms = remainingMs.value;
if (ms === null || warned || props.sending || initialMs <= LONG_CARD_MS || ms > WARN_AT_MS)
return;
warned = true;
emit("announce", t("assistant.approval.expiresSoon"));
if (ms === null || props.sending) return;
const due = WARN_ATS_MS.find((at) => ms <= at && !warned.has(at));
if (due === undefined) return;
for (const at of WARN_ATS_MS) if (ms <= at) warned.add(at);
const key = extendsSpent.value
? "assistant.announce.approvalExpiringFinal"
: "assistant.announce.approvalExpiring";
const line = t(key, { title: heading.value, duration: duration(ms) });
const focused = root.value?.contains(document.activeElement);
emit("announce", focused ? line : t(idle.jumpKey(), { line, keys: t(idle.spokenKeys()) }));
}
onMounted(async () => {
@@ -699,6 +737,19 @@ onBeforeUnmount(() => {
offs.forEach((off) => off());
});
watch(
() => props.card.expiresAt,
(next, before) => {
if (typeof next !== "number" || typeof before !== "number" || next <= before) return;
warned = new Set();
now.value = Date.now();
emit(
"announce",
t("assistant.announce.approvalExtended", { duration: duration(next - now.value) }),
);
},
);
watch(
() => props.card.id,
() => (selected.value = props.card.targets.map((target) => target.id ?? target.name)),
@@ -55,6 +55,7 @@ function mountRecord(
readonly?: boolean;
live?: boolean;
held?: { value: boolean };
gated?: boolean;
} = {},
) {
const message: ChatMessage = {
@@ -75,6 +76,7 @@ function mountRecord(
askAgain: vi.fn(),
stop: vi.fn(),
undo: vi.fn(async () => ({ ok: true }) as const),
gated: () => over.gated === true,
};
const wrapper = mount(AssistantApprovalResult, {
props: {
@@ -102,6 +104,14 @@ describe("AssistantApprovalResult", () => {
expect(wrapper.find('[data-test="assistant-approval-result"]').exists()).toBe(false);
});
it("offers no Ask again on an expired card a gated server would only refuse (PO B96)", () => {
const expired = { outcome: { state: "expired" as const, at: Date.now() } };
const open = mountRecord(expired);
expect(open.wrapper.text()).toContain("Ask again");
const gated = mountRecord(expired, { gated: true });
expect(gated.wrapper.text()).not.toContain("Ask again");
});
it("is never green before /confirm answers", () => {
const { wrapper } = mountRecord({ outcome: { state: "sending", at: Date.now() } });
expect(line(wrapper)).toBe("Sending your decision…");
@@ -342,6 +342,7 @@ const action = computed<{
label: t("assistant.approval.result.sendAgain"),
run: () => host.sendAgain(current, id),
};
if (state === "expired" && host.gated?.()) return null;
if (state === "expired" || (state === "unconfirmed" && !card.value.destructive))
return {
id: "ask-again",
@@ -27,7 +27,9 @@ import type {
/** The card as its component renders it: the transcript's block, plus the pending card's ids and expiry while it waits. */
export type ApprovalCardView = ApprovalBlockData &
Partial<Pick<PendingApproval, "sessionId" | "turnId" | "expiresAt" | "askedIn">>;
Partial<
Pick<PendingApproval, "sessionId" | "turnId" | "expiresAt" | "askedIn" | "userExtendsLeft">
>;
/** Every state a record renders; `waiting_elsewhere` is a card another tab of this browser holds. */
export type ApprovalResultState = ApprovalOutcome["state"] | "waiting_elsewhere";
@@ -55,6 +57,8 @@ export interface AssistantApprovalsHost {
/** Stop on a turn another tab is waiting on. */
stop(sid: Sid): void;
undo(sid: Sid, ledgerId: string): Promise<ActionResult>;
/** No split budgets: cards are Deny and Reply only, so an expired one offers no Ask again (PO B96). */
gated?(): boolean;
}
/** Provided by the shell beside the evidence host. */
@@ -329,6 +329,8 @@ const FAILURE_LINES: Partial<Record<AssistantErrorCode, { key: I18nKey; actions:
const FAILED_PROPOSAL_CODES: ReadonlySet<string> = new Set([
"offline",
"rate_limited",
"run_limit_member",
"run_limit_org",
"turn_limit",
"unreachable",
"agent_unavailable",
@@ -911,7 +913,7 @@ function failureLine(state: Extract<HelperState, { kind: "failed" }>): Line {
[isAdmin.value ? "manage-usage" : "ask-admin"],
);
}
if (code === "rate_limited" || code === "turn_limit")
if (code === "rate_limited" || code === "turn_limit" || code.startsWith("run_limit_"))
return failed(
retryWait.value > 0
? t("assistant.helper.rateLimited", { seconds: retryWait.value })
@@ -0,0 +1,212 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";
import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant";
import AssistantDecisionNotice from "./AssistantDecisionNotice.vue";
import { fakeRuns, runRow } from "./runsTestKit";
const { fake } = vi.hoisted(() => ({ fake: { current: null as any, runs: null as any } }));
vi.mock("@/composables/assistant", async (importOriginal) => ({
...(await importOriginal<typeof import("@/composables/assistant")>()),
useAssistantStore: () => fake.current.store,
useAssistantRuns: () => fake.runs.runs,
}));
const NOW = 5_000_000;
const decision = (sid: string, over = {}) =>
runRow(sid, {
group: "needs_you",
status: "needsApproval",
request: `card-${sid}`,
expiresAt: NOW + 300_000,
...over,
});
function setup(decisions = [decision("a")]) {
fake.current = fakeAssistant();
fake.runs = fakeRuns();
fake.runs.decisions.value = decisions;
const wrapper = mount(AssistantDecisionNotice, {
attachTo: document.body,
global: { plugins: [fakeRouter(), fakeVuex()] },
});
return { wrapper, store: fake.current.store, runs: fake.runs };
}
const notice = () => document.querySelector<HTMLElement>('[data-test="assistant-decision-notice"]');
const press = (id: string) =>
document.querySelector<HTMLElement>(`[data-test="assistant-decision-notice-${id}"]`)?.click();
describe("AssistantDecisionNotice", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
});
it("names the region, has no live role, and never takes focus", async () => {
setup();
await nextTick();
const region = notice();
expect(region?.tagName).toBe("SECTION");
expect(region?.getAttribute("aria-label")).toBe("O2 Assistant notice");
expect(region?.getAttribute("aria-live")).toBeNull();
expect(region?.textContent).toContain("O2 Assistant needs your decision");
expect(region?.textContent).toContain("Chat a");
expect(region?.textContent).toContain("Expires in 5:00");
expect(document.activeElement).toBe(document.body);
});
it("stays away when there is no decision", async () => {
setup([]);
await nextTick();
expect(notice()).toBeNull();
});
it("gives way to the Runs button while a visible container shows another chat", async () => {
const { store } = setup();
store.state.shell.placement = { kind: "sidebar" };
store.state.shell.activeSid = "other";
await nextTick();
expect(notice()).toBeNull();
});
it("replaces the line for a second request, and Review opens the list", async () => {
const { runs } = setup([decision("a"), decision("b")]);
await nextTick();
expect(notice()?.textContent).toContain("2 chats need your decision");
press("review");
expect(runs.runs.requestOpen).toHaveBeenCalled();
expect(runs.runs.open).not.toHaveBeenCalled();
});
it("Review opens the one chat on its card", async () => {
const { runs } = setup();
await nextTick();
press("review");
expect(runs.runs.open).toHaveBeenCalledWith(expect.objectContaining({ sid: "a" }), {
focus: "card",
});
});
it("Hide notice leaves the request pending and hands focus back", async () => {
const { wrapper, runs } = setup();
await nextTick();
press("hide");
await nextTick();
expect(notice()).toBeNull();
expect(wrapper.emitted("hidden")).toHaveLength(1);
expect(runs.runs.decisions.value).toHaveLength(1);
});
it("comes back at each warning even when hidden, and speaks each once", async () => {
const { wrapper } = setup([decision("a", { expiresAt: NOW + 90_000 })]);
await nextTick();
press("hide");
await nextTick();
await vi.advanceTimersByTimeAsync(30_000);
await nextTick();
expect(notice()?.textContent).toContain("Expires soon · 1:00");
press("hide");
await nextTick();
expect(notice()).toBeNull();
await vi.advanceTimersByTimeAsync(40_000);
await nextTick();
expect(notice()?.textContent).toContain("Expires soon · 0:20");
await vi.advanceTimersByTimeAsync(10_000);
const spoken = (wrapper.emitted("announce") ?? []).map(([text]) => String(text));
expect(spoken.filter((text) => text.startsWith("Approval expires in"))).toEqual([
"Approval expires in 60 seconds: Chat a. Review it from the O2 Assistant notice.",
"Approval expires in 20 seconds: Chat a. Review it from the O2 Assistant notice.",
]);
});
it("says when no more time can be added", async () => {
const { wrapper } = setup([decision("a", { expiresAt: NOW + 50_000, extendsLeft: 0 })]);
await vi.advanceTimersByTimeAsync(3_000);
const spoken = (wrapper.emitted("announce") ?? []).map(([text]) => String(text));
expect(spoken.join()).toContain("No more time can be added. Review it from");
});
it("warns even while a visible container shows another chat", async () => {
const { store } = setup([decision("a", { expiresAt: NOW + 70_000 })]);
store.state.shell.placement = { kind: "sidebar" };
store.state.shell.activeSid = "other";
await nextTick();
expect(notice()).toBeNull();
await vi.advanceTimersByTimeAsync(10_000);
await nextTick();
expect(notice()?.textContent).toContain("Expires soon");
});
it("keeps background sentences 3 s apart and drops none", async () => {
const { wrapper } = setup([decision("a"), decision("b")]);
await nextTick();
expect(wrapper.emitted("announce")).toHaveLength(1);
await vi.advanceTimersByTimeAsync(3_000);
expect(wrapper.emitted("announce")).toHaveLength(2);
});
it("leaves the first request's sentence to the header's own light", async () => {
fake.current = fakeAssistant();
fake.runs = fakeRuns();
const { store } = fake.current;
const sid = store.createSession({
origin: { entryKind: "header_button", objectKind: "none", container: "sidebar" },
thread: "new",
});
store.state.sessions[sid].pendingApproval = { id: "c1" };
fake.runs.decisions.value = [decision(sid)];
const wrapper = mount(AssistantDecisionNotice, {
attachTo: document.body,
global: { plugins: [fakeRouter(), fakeVuex()] },
});
await flushPromises();
expect(wrapper.emitted("announce")).toBeUndefined();
});
it("waits for a modal dialog to close", async () => {
setup();
const dialog = document.createElement("div");
dialog.setAttribute("data-o2-dialog", "");
dialog.setAttribute("data-state", "open");
document.body.appendChild(dialog);
await vi.advanceTimersByTimeAsync(1_000);
await nextTick();
expect(notice()).toBeNull();
dialog.remove();
await vi.advanceTimersByTimeAsync(1_000);
await nextTick();
expect(notice()).not.toBeNull();
});
it("leaves when the request does", async () => {
const { runs } = setup();
await nextTick();
runs.decisions.value = [];
await nextTick();
expect(notice()).toBeNull();
});
});
@@ -0,0 +1,238 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<Teleport to="body">
<section
v-if="line"
:aria-label="t('assistant.notice.region', { product })"
data-assistant-surface
data-dd-privacy="mask"
class="bg-surface-base border-border-default rounded-surface fixed end-4 bottom-[calc(var(--o-assistant-inset-bottom,0rem)+1rem)] z-5000 flex w-[min(22.5rem,calc(100vw-2rem))] flex-wrap items-center justify-between gap-x-3 gap-y-2 border p-3 shadow-lg"
data-test="assistant-decision-notice"
>
<div class="flex min-w-0 flex-col gap-0.5">
<OText variant="body" as="p" class="break-words" data-test="assistant-decision-notice-line">
{{ line }}
</OText>
<OText v-if="subject" variant="meta" as="p" class="break-words">
{{ runTitle(subject, t) }}
</OText>
<OText
v-if="clock"
variant="meta"
as="p"
aria-live="off"
class="tabular-nums"
data-test="assistant-decision-notice-clock"
>
{{ clock }}
</OText>
</div>
<div class="flex shrink-0 items-center gap-2">
<OButton
variant="ghost"
size="sm"
class="max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-decision-notice-hide"
@click="hide"
>
{{ t("assistant.notice.hide") }}
</OButton>
<OButton
variant="outline"
size="sm"
class="max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-decision-notice-review"
@click="review"
>
{{ t("assistant.runs.review") }}
</OButton>
</div>
</section>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRoute } from "vue-router";
import {
approvalClock,
approvalDuration,
shownSids,
useAssistantName,
useAssistantRuns,
useAssistantStore,
type RunRow,
} from "@/composables/assistant";
import OButton from "@/lib/core/Button/OButton.vue";
import OText from "@/lib/core/Typography/OText.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { runTitle } from "./runsText";
const TICK_MS = 1_000;
/** Background sentences stay this far apart, and a warning is never dropped (`95` §2.1). */
const SPEAK_GAP_MS = 3_000;
const WARN_FIRST_MS = 60_000;
const WARN_LAST_MS = 20_000;
const SHARED_ROUTES: ReadonlySet<unknown> = new Set(["sharedChat", "publicSharedChat"]);
const emit = defineEmits<{
/** One sentence for the header region, the notice's only voice. */
announce: [text: I18nText];
/** Hide notice was pressed; focus goes to the header button. */
hidden: [];
}>();
const { t } = useI18nTyped();
const { product } = useAssistantName();
const store = useAssistantStore();
const runs = useAssistantRuns();
const route = useRoute();
const now = ref(Date.now());
const modalOpen = ref(false);
const hiddenRequests = ref(new Set<string>());
const warning = ref(new Set<string>());
const warnedWindows = new Set<string>();
const queue: I18nText[] = [];
let gap: ReturnType<typeof setTimeout> | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
const requestOf = (row: RunRow) => row.request ?? row.sid;
const decisions = computed(() => runs.decisions.value);
const warned = computed(() => decisions.value.filter((row) => warning.value.has(requestOf(row))));
// A visible container showing another chat changes only the Runs button, until a card is about to expire.
const plain = computed(() =>
shownSids(store.state).length
? []
: decisions.value.filter((row) => !hiddenRequests.value.has(requestOf(row))),
);
const blocked = computed(() => modalOpen.value || SHARED_ROUTES.has(route?.name));
const subject = computed<RunRow | null>(() => {
if (blocked.value) return null;
return warned.value[0] ?? (plain.value.length === 1 ? plain.value[0] : null);
});
const line = computed<I18nText | null>(() => {
if (subject.value) return t("assistant.notice.decision", { product });
if (blocked.value || !plain.value.length) return null;
return t("assistant.notice.decisionMany", { n: plain.value.length });
});
const clock = computed<I18nText | null>(() => {
const until = subject.value?.expiresAt;
if (typeof until !== "number") return null;
const time = raw(approvalClock(until - now.value));
return until - now.value <= WARN_FIRST_MS
? t("assistant.approval.expiringSoon", { time })
: t("assistant.approval.expiresIn", { time });
});
function drain(): void {
if (gap || !queue.length) return;
emit("announce", queue.shift() as I18nText);
gap = setTimeout(() => {
gap = null;
drain();
}, SPEAK_GAP_MS);
}
function speak(text: I18nText): void {
queue.push(text);
drain();
}
function warningLine(row: RunRow, left: number): I18nText {
const key =
row.extendsLeft === 0
? "assistant.announce.approvalExpiringNoticeFinal"
: "assistant.announce.approvalExpiringNotice";
return t(key, { title: runTitle(row, t), duration: approvalDuration(left, t), product });
}
// A hidden notice comes back at each warning of each window, without taking focus (`20` §6.2).
function checkWarning(row: RunRow): void {
if (row.expiresAt === null) return;
const left = row.expiresAt - now.value;
const request = requestOf(row);
if (left <= 0 || left > WARN_FIRST_MS) return void warning.value.delete(request);
const window = `${request}:${row.expiresAt}:${left <= WARN_LAST_MS ? WARN_LAST_MS : WARN_FIRST_MS}`;
if (warnedWindows.has(window)) return;
warnedWindows.add(window);
warning.value.add(request);
hiddenRequests.value.delete(request);
speak(warningLine(row, left));
}
function tick(): void {
now.value = Date.now();
modalOpen.value = !!document.querySelector('[data-o2-dialog][data-state="open"]');
decisions.value.forEach(checkWarning);
}
function hide(): void {
const rows = warned.value.length ? warned.value : plain.value;
rows.forEach((row) => {
hiddenRequests.value.add(requestOf(row));
warning.value.delete(requestOf(row));
});
emit("hidden");
}
function review(): void {
const row = subject.value;
if (row) void runs.open(row, { focus: "card" });
else void runs.requestOpen();
}
// The header's light names one chat; a later request of the same light has no other voice.
function announceNew(rows: RunRow[], before: RunRow[] = []): void {
const known = new Set(before.map(requestOf));
const lit = store.headerLight.value.sid;
for (const row of rows) {
if (known.has(requestOf(row))) continue;
if (row.sid !== lit || !store.state.sessions[row.sid])
speak(t("assistant.header.announceNeedsYou", { product, title: runTitle(row, t) }));
}
}
watch(
decisions,
(rows, before) => {
const live = new Set(rows.map(requestOf));
for (const set of [hiddenRequests.value, warning.value])
for (const request of set) if (!live.has(request)) set.delete(request);
announceNew(rows, before);
if (rows.length && !timer) {
tick();
timer = setInterval(tick, TICK_MS);
} else if (!rows.length && timer) {
clearInterval(timer);
timer = null;
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
if (gap) clearTimeout(gap);
});
</script>
@@ -0,0 +1,176 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant";
import AssistantRunsButton from "./AssistantRunsButton.vue";
import { fakeRuns, runRow } from "./runsTestKit";
const { fake, md } = vi.hoisted(() => ({
fake: { current: null as any, runs: null as any },
md: { value: true },
}));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
lgUp: computed(() => md.value),
mdUp: computed(() => md.value),
isMobile: computed(() => !md.value),
isTablet: computed(() => false),
isDesktop: computed(() => md.value),
}),
};
});
vi.mock("@/composables/assistant", async (importOriginal) => ({
...(await importOriginal<typeof import("@/composables/assistant")>()),
useAssistantStore: () => fake.current.store,
useAssistantRuns: () => fake.runs.runs,
}));
// An open popover closes the previous one, so each spec's wrapper is unmounted, never wiped from the DOM.
const mounted: { unmount(): void }[] = [];
function mountButton() {
const wrapper = mount(AssistantRunsButton, {
attachTo: document.body,
global: { plugins: [fakeRouter(), fakeVuex()] },
});
mounted.push(wrapper);
return wrapper;
}
function setup(rows = [runRow("a"), runRow("b", { group: "needs_you", status: "needsApproval" })]) {
fake.current = fakeAssistant();
fake.runs = fakeRuns(rows);
const wrapper = mountButton();
return { wrapper, store: fake.current.store, runs: fake.runs };
}
const button = (wrapper: ReturnType<typeof setup>["wrapper"]) =>
wrapper.find('[data-test="assistant-runs-button"]');
describe("AssistantRunsButton", () => {
beforeEach(() => {
md.value = true;
});
afterEach(() => {
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
});
it("stays away while no other chat is listed", () => {
const { wrapper } = setup([]);
expect(button(wrapper).exists()).toBe(false);
});
it("starts its name with the number it shows, then counts per signal (WCAG 2.5.3, PO B53)", () => {
const { wrapper } = setup();
const target = button(wrapper);
expect(target.attributes("aria-label")).toBe("2 other chats: 1 answering · 1 needs you");
expect(target.attributes("data-state")).toBe("attention");
expect(target.text()).toContain("2");
});
it("does not count the chat its own container shows", async () => {
const { wrapper, store } = setup();
store.state.shell.activeSid = "a";
await nextTick();
expect(button(wrapper).attributes("aria-label")).toBe("1 other chat: 1 needs you");
store.state.shell.activeSid = null;
await nextTick();
expect(button(wrapper).attributes("aria-expanded")).toBe("false");
});
it("opens the list and reports it expanded", async () => {
const { wrapper } = setup();
await button(wrapper).trigger("click");
await flushPromises();
expect(button(wrapper).attributes("aria-expanded")).toBe("true");
expect(document.querySelector('[data-test="assistant-runs-menu"]')).not.toBeNull();
});
it("closes on a second press, though the popover already closed on the pointer going down", async () => {
const { wrapper } = setup();
await button(wrapper).trigger("click");
await flushPromises();
await button(wrapper).trigger("pointerdown");
wrapper.findComponent(OPopover).vm.$emit("update:open", false);
await flushPromises();
await button(wrapper).trigger("click");
await flushPromises();
expect(button(wrapper).attributes("aria-expanded")).toBe("false");
});
it("opens on a request from elsewhere, even with nothing else to show", async () => {
const { wrapper, runs } = setup([]);
await runs.runs.requestOpen();
await flushPromises();
expect(runs.runs.takeOpenRequest).toHaveBeenCalled();
expect(button(wrapper).attributes("aria-expanded")).toBe("true");
});
it("takes a request made before it mounted", async () => {
fake.current = fakeAssistant();
fake.runs = fakeRuns();
fake.runs.runs.openRequested.value = true;
const wrapper = mountButton();
await flushPromises();
expect(button(wrapper).attributes("aria-expanded")).toBe("true");
});
it("uses a sheet below md", async () => {
md.value = false;
const { wrapper } = setup();
await button(wrapper).trigger("click");
await flushPromises();
expect(document.querySelector('[data-test="assistant-runs-sheet-back"]')).not.toBeNull();
});
it("says once that a slot freed while the refusal shows", async () => {
const { wrapper, store, runs } = setup();
const sid = store.createSession({
origin: { entryKind: "header_button", objectKind: "none", container: "sidebar" },
thread: "new",
});
store.state.shell.activeSid = sid;
store.state.sessions[sid].turn = {
state: "refused",
error: { code: "run_limit_member", limit: 3 },
};
store.state.sessions[sid].turnMeta.turnId = "t1";
runs.active.value = 3;
await nextTick();
runs.active.value = 2;
await nextTick();
const region = wrapper.find('[data-test="assistant-runs-status-region"]');
expect(region.text()).toBe("A chat finished. You can send now.");
});
it("stays quiet when a chat ends with no refusal showing", async () => {
const { wrapper, runs } = setup();
runs.active.value = 3;
await nextTick();
runs.active.value = 2;
await nextTick();
expect(wrapper.find('[data-test="assistant-runs-status-region"]').text()).toBe("");
});
});
@@ -0,0 +1,171 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<template v-if="shown">
<OPopover
:open="open && mdUp"
side="bottom"
align="end"
:aria-label="t('assistant.runs.heading')"
content-class="w-100 max-w-[calc(100vw-2rem)]"
privacy="mask"
@update:open="setOpen"
>
<template #anchor>
<OButton
ref="button"
variant="ghost"
size="sm"
:aria-label="name"
:aria-expanded="open"
:data-state="signal"
class="shrink-0 gap-1 max-md:min-h-11 max-md:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11"
data-test="assistant-runs-button"
@pointerdown="notePress"
@click="toggle"
>
<AssistantStatusDot :state="signal" />
<span aria-hidden="true" class="text-xs tabular-nums">{{ others.length }}</span>
<OTooltip v-if="!open" side="bottom" :content="name" />
</OButton>
</template>
<AssistantRunsMenu v-if="open && mdUp" @close="setOpen(false)" />
</OPopover>
<OSheet
v-if="!mdUp"
:open="open"
:snap="snap"
:snap-points="['half', 'full']"
v-bind="{ ariaLabel: t('assistant.runs.heading') }"
:expand-label="t('assistant.shell.sheetExpand')"
:collapse-label="t('assistant.shell.sheetPeek')"
data-test="assistant-runs-sheet"
@update:snap="(next) => (snap = next)"
@update:open="setOpen"
>
<template #header>
<div class="flex justify-start px-2">
<AssistantIconButton
:label="t('assistant.runs.back')"
:icon="assistantGlyph.back"
data-test="assistant-runs-sheet-back"
@click="setOpen(false)"
/>
</div>
</template>
<AssistantRunsMenu v-if="open" @close="setOpen(false)" />
</OSheet>
</template>
<AssistantStatusRegion ref="region" data-test="assistant-runs-status-region" />
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import AssistantStatusDot from "@/components/ai-assistant/common/AssistantStatusDot.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { provideAssistantSurface } from "@/components/ai-assistant/common/assistantSurface";
import AssistantStatusRegion from "@/components/ai-assistant/shell/AssistantStatusRegion.vue";
import { runsCounts, useAssistantRuns, useAssistantStore } from "@/composables/assistant";
import useBreakpoint from "@/composables/useBreakpoint";
import OButton from "@/lib/core/Button/OButton.vue";
import OSheet from "@/lib/overlay/Sheet/OSheet.vue";
import type { SheetSnap } from "@/lib/overlay/Sheet/OSheet.types";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useI18nTyped } from "@/types/i18n";
import AssistantRunsMenu from "./AssistantRunsMenu.vue";
import { runSignal, runsSummary } from "./runsText";
// The list is teleported to body, so it carries the assistant colour roles with it.
provideAssistantSurface();
const { t } = useI18nTyped();
const store = useAssistantStore();
const runs = useAssistantRuns();
const { mdUp } = useBreakpoint();
const button = ref<InstanceType<typeof OButton> | null>(null);
const region = ref<InstanceType<typeof AssistantStatusRegion> | null>(null);
const open = ref(false);
const snap = ref<SheetSnap>("full");
let freedFor: string | null = null;
let openAtPress: boolean | null = null;
const activeSession = computed(() => {
const sid = store.state.shell.activeSid;
return sid ? store.state.sessions[sid] : undefined;
});
const others = computed(() =>
runs.rows.value.filter((row) => row.sid !== store.state.shell.activeSid),
);
const counts = computed(() => runsCounts(others.value));
const signal = computed(() => runSignal(counts.value));
const name = computed(() =>
t(
"assistant.runs.button",
{
count: others.value.length,
summary: runsSummary(counts.value, ["answering", "needs", "failed", "ready"], t),
},
others.value.length,
),
);
// A request from Show running chats opens the list even with nothing else to show.
const shown = computed(() => others.value.length > 0 || open.value);
function setOpen(next: boolean): void {
open.value = next;
if (!next && !mdUp.value)
void nextTick(() => (button.value?.$el as HTMLElement | undefined)?.focus());
}
// The popover closes on the pointer going down outside it, so the click must not read that as closed.
function notePress(): void {
openAtPress = open.value;
}
function toggle(): void {
setOpen(!(openAtPress ?? open.value));
openAtPress = null;
}
watch(
() => runs.openRequested.value,
(requested) => {
if (!requested) return;
runs.takeOpenRequest();
setOpen(true);
},
{ immediate: true },
);
// Once per refusal: a second chat ending changes nothing the reader has not been told (`95` §2.2).
watch(
() => runs.active.value,
(active, before) => {
const session = activeSession.value;
const turn = session?.turn;
if (active >= before || turn?.state !== "refused" || turn.error.code !== "run_limit_member")
return;
if (freedFor === session?.turnMeta.turnId) return;
freedFor = session?.turnMeta.turnId ?? null;
region.value?.announce(t("assistant.limit.slotFree"));
},
);
</script>
@@ -0,0 +1,160 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant";
import AssistantRunsMenu from "./AssistantRunsMenu.vue";
import { fakeRuns, runRow } from "./runsTestKit";
const { fake } = vi.hoisted(() => ({ fake: { current: null as any, runs: null as any } }));
vi.mock("@/composables/assistant", async (importOriginal) => ({
...(await importOriginal<typeof import("@/composables/assistant")>()),
useAssistantStore: () => fake.current.store,
useAssistantRuns: () => fake.runs.runs,
}));
const waiting = runRow("wait", {
group: "needs_you",
status: "needsApproval",
request: "c1",
expiresAt: Date.now() + 240_000,
});
const answering = runRow("run");
const done = runRow("done", { group: "ready", status: "ready" });
function setup(rows = [waiting, answering, done]) {
fake.current = fakeAssistant();
fake.runs = fakeRuns(rows);
const wrapper = mount(AssistantRunsMenu, {
attachTo: document.body,
global: {
plugins: [
fakeRouter([
{ path: "/assistant/:chatId?", name: "assistant", component: { template: "<div />" } },
]),
fakeVuex(),
],
},
});
return { wrapper, store: fake.current.store, runs: fake.runs.runs, source: fake.runs };
}
const rowsOf = (wrapper: ReturnType<typeof setup>["wrapper"]) =>
wrapper.findAll('[data-test="assistant-runs-row-open"]');
describe("AssistantRunsMenu", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("states the cap and the summary, zero parts dropped", () => {
const { wrapper, source } = setup();
source.active.value = 2;
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.get('[data-test="assistant-runs-capacity"]').text()).toBe(
"2 of 3 running chats",
);
expect(wrapper.get('[data-test="assistant-runs-summary"]').text()).toBe(
"1 needs you · 1 answering",
);
});
});
it("names each row, with the time left on a pending card", () => {
const { wrapper } = setup();
const [first, second] = rowsOf(wrapper);
expect(first.attributes("aria-label")).toBe(
"Chat wait, O2 Assistant, Needs your approval, 4 minutes left",
);
expect(second.attributes("aria-label")).toContain("Chat run, O2 Assistant, Answering");
});
it("offers Stop on an answering row and Review on a waiting one, with the title in their names", async () => {
const { wrapper, runs } = setup();
const stop = wrapper.get('[data-test="assistant-runs-row-stop"]');
expect(stop.attributes("aria-label")).toBe("Stop Chat run");
await stop.trigger("click");
expect(runs.stop).toHaveBeenCalledWith(answering);
const review = wrapper.get('[data-test="assistant-runs-row-review"]');
expect(review.attributes("aria-label")).toBe("Review Chat wait");
await review.trigger("click");
expect(runs.open).toHaveBeenCalledWith(waiting, expect.objectContaining({ focus: "card" }));
expect(wrapper.emitted("close")).toHaveLength(1);
});
it("opens a row on click and marks the chat on screen", async () => {
const { wrapper, runs, store } = setup();
store.state.shell.activeSid = "run";
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-sid="run"]').attributes("data-current")).toBe("");
expect(wrapper.find('[data-sid="run"]').text()).toContain("This chat");
await rowsOf(wrapper)[0].trigger("click");
expect(runs.open).toHaveBeenCalledWith(waiting, expect.objectContaining({ focus: "composer" }));
});
it("moves through the rows with the arrow keys, Home and End, with one Tab stop", async () => {
const { wrapper } = setup();
const rows = rowsOf(wrapper);
expect(rows.map((row) => row.attributes("tabindex"))).toEqual(["0", "-1", "-1"]);
rows[0].element.focus();
await rows[0].trigger("keydown", { key: "ArrowDown" });
expect(document.activeElement).toBe(rows[1].element);
await rows[1].trigger("keydown", { key: "End" });
expect(document.activeElement).toBe(rows[2].element);
await rows[2].trigger("keydown", { key: "Home" });
expect(document.activeElement).toBe(rows[0].element);
await wrapper.vm.$nextTick();
expect(rowsOf(wrapper).map((row) => row.attributes("tabindex"))).toEqual(["0", "-1", "-1"]);
});
it("clears only a finished row with Delete", async () => {
const { wrapper, runs } = setup();
const rows = rowsOf(wrapper);
await rows[0].trigger("keydown", { key: "Delete" });
expect(runs.clear).not.toHaveBeenCalled();
await rows[2].trigger("keydown", { key: "Delete" });
expect(runs.clear).toHaveBeenCalledWith("done");
});
it("offers Clear finished only when a finished row is listed", async () => {
const { wrapper, runs } = setup();
await wrapper.get('[data-test="assistant-runs-clear-finished"]').trigger("click");
expect(runs.clearFinished).toHaveBeenCalled();
expect(
setup([answering]).wrapper.find('[data-test="assistant-runs-clear-finished"]').exists(),
).toBe(false);
});
it("shows another org's chat with no way to open it in place (PO B51)", () => {
const { wrapper } = setup([runRow("x", { otherOrg: true, org: "acme", title: null })]);
const row = wrapper.get('[data-test="assistant-runs-row-other-org"]');
expect(row.text()).toContain("A chat in acme");
expect(row.text()).toContain("In acme");
expect(wrapper.find('[data-test="assistant-runs-row-open"]').exists()).toBe(false);
expect(wrapper.find('[data-test="assistant-runs-row-menu"]').exists()).toBe(false);
});
it("moves to the full view from the row menu", async () => {
const { wrapper, runs, store } = setup([answering]);
const move = vi.spyOn(store, "moveTo");
await (wrapper.vm as any).$.setupState.openFull(answering);
await flushPromises();
expect(runs.open).toHaveBeenCalledWith(answering);
expect(move).toHaveBeenCalledWith({ kind: "full" });
});
});
@@ -0,0 +1,292 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="flex max-h-[min(32rem,70vh)] min-w-0 flex-col gap-2 p-3"
data-test="assistant-runs-menu"
>
<div class="flex min-w-0 flex-col gap-0.5">
<OText variant="label" as="h2" class="text-text-heading">
{{ t("assistant.runs.heading") }}
</OText>
<OText variant="meta" as="p" data-test="assistant-runs-capacity">
{{ t("assistant.runs.capacity", { active: runs.active.value, cap: runs.cap.value }) }}
</OText>
<OText v-if="stateSummary" variant="meta" as="p" data-test="assistant-runs-summary">
{{ stateSummary }}
</OText>
</div>
<ul
ref="list"
:aria-label="t('assistant.runs.heading')"
class="flex min-w-0 flex-col gap-0.5 overflow-y-auto"
data-test="assistant-runs-list"
>
<li
v-for="row in runs.rows.value"
:key="row.sid"
class="rounded-default hover:bg-surface-subtle flex min-w-0 items-center gap-1 ps-1"
:data-sid="row.sid"
:data-group="row.group"
:data-status="row.status"
:data-current="row.sid === activeSid ? '' : undefined"
data-test="assistant-runs-row"
>
<div
v-if="row.otherOrg"
class="flex min-w-0 flex-1 items-start gap-1.5 px-2 py-1.5"
data-test="assistant-runs-row-other-org"
>
<AssistantStatusDot :state="rowSignal(row)" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<OTruncatedText class="text-text-body text-sm">{{ titleOf(row) }}</OTruncatedText>
<OText variant="meta" as="span">
{{ t("assistant.runs.inOrg", { org: raw(orgName(row.org)) }) }}
</OText>
</span>
</div>
<OButton
v-else
variant="ghost"
size="sm"
block
:tabindex="row.sid === focusSid ? 0 : -1"
:aria-label="rowName(row)"
:aria-current="row.sid === activeSid ? 'true' : undefined"
class="h-auto! min-h-8 min-w-0 flex-1 justify-start! py-1.5 text-start max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-runs-row-open"
@click="openRow(row, 'composer', $event)"
@focus="onRowFocus(row)"
@keydown="onKeydown"
>
<span class="flex min-w-0 flex-1 items-start gap-1.5">
<AssistantStatusDot :state="rowSignal(row)" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<OTruncatedText class="text-text-body text-sm">{{ titleOf(row) }}</OTruncatedText>
<OText variant="meta" as="span" class="tabular-nums">
{{ metaOf(row) }}
</OText>
<OText v-if="row.sid === activeSid" variant="meta" as="span">
{{ t("assistant.runs.thisChat") }}
</OText>
</span>
</span>
</OButton>
<template v-if="!row.otherOrg">
<OButton
v-if="row.group === 'answering'"
variant="ghost"
size="xs"
:aria-label="t('assistant.runs.stopName', { title: titleOf(row) })"
class="shrink-0 max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-runs-row-stop"
@click="void runs.stop(row)"
>
{{ t("assistant.runs.stop") }}
</OButton>
<OButton
v-else-if="row.group === 'needs_you'"
variant="outline"
size="xs"
:aria-label="t('assistant.runs.reviewName', { title: titleOf(row) })"
class="shrink-0 max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-runs-row-review"
@click="openRow(row, 'card', $event)"
>
{{ t("assistant.runs.review") }}
</OButton>
<ODropdown align="end" privacy="mask">
<template #trigger>
<AssistantIconButton
:label="t('assistant.runs.rowActions', { title: titleOf(row) })"
:icon="assistantGlyph.more"
no-tooltip
data-test="assistant-runs-row-menu"
/>
</template>
<ODropdownItem
data-test="assistant-runs-row-open-here"
@select="openRow(row, 'composer', $event)"
>
{{ t("assistant.runs.openHere") }}
</ODropdownItem>
<ODropdownItem data-test="assistant-runs-row-open-full" @select="openFull(row)">
{{ t("assistant.runs.openFull") }}
</ODropdownItem>
<ODropdownItem
v-if="isFinished(row)"
data-test="assistant-runs-row-clear"
@select="runs.clear(row.sid)"
>
{{ t("assistant.runs.clear") }}
</ODropdownItem>
</ODropdown>
</template>
</li>
</ul>
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2">
<OText variant="meta" as="p" class="min-w-0">{{ t("assistant.runs.footer") }}</OText>
<OButton
v-if="ownRows.some(isFinished)"
variant="ghost"
size="xs"
class="max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-runs-clear-finished"
@click="runs.clearFinished()"
>
{{ t("assistant.runs.clearFinished") }}
</OButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import AssistantStatusDot from "@/components/ai-assistant/common/AssistantStatusDot.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import {
approvalDuration,
runsCounts,
useAssistantName,
useAssistantRuns,
useAssistantStore,
type RunRow,
} from "@/composables/assistant";
import OButton from "@/lib/core/Button/OButton.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { rowSignal, runAgent, runStatus, runsSummary, runTitle } from "./runsText";
const ELAPSED_TICK_MS = 1_000;
const emit = defineEmits<{ close: [] }>();
const { t } = useI18nTyped();
const { product } = useAssistantName();
const store = useAssistantStore();
const runs = useAssistantRuns();
const router = useRouter();
const vuex = useStore();
const list = ref<HTMLElement | null>(null);
const now = ref(Date.now());
const focused = ref<string | null>(null);
// The time left is read out when a row gets focus, never per tick (`95` §1).
const spokenAt = ref(Date.now());
const timer = setInterval(() => (now.value = Date.now()), ELAPSED_TICK_MS);
const activeSid = computed(() => store.state.shell.activeSid);
const ownRows = computed(() => runs.rows.value.filter((row) => !row.otherOrg));
const focusSid = computed(() =>
ownRows.value.some((row) => row.sid === focused.value)
? focused.value
: (ownRows.value[0]?.sid ?? null),
);
const stateSummary = computed(() =>
runsSummary(runsCounts(ownRows.value), ["needs", "answering"], t),
);
function isFinished(row: RunRow): boolean {
return row.group === "failed" || row.group === "ready";
}
function titleOf(row: RunRow): I18nText {
return runTitle(row, t);
}
function metaOf(row: RunRow): I18nText {
return t("assistant.runs.rowMeta", {
agent: runAgent(row, product, t),
status: runStatus(row, now.value, t),
});
}
function rowName(row: RunRow): I18nText {
const params = {
title: titleOf(row),
agent: runAgent(row, product, t),
status: runStatus({ ...row, expiresAt: null }, spokenAt.value, t),
};
if (row.expiresAt === null) return t("assistant.runs.rowName", params);
const time = approvalDuration(row.expiresAt - spokenAt.value, t);
return t("assistant.runs.rowExpires", { ...params, time });
}
function orgName(id: string): string {
const orgs: { identifier: string; name?: string }[] = vuex.state.organizations ?? [];
return orgs.find((org) => org.identifier === id)?.name ?? id;
}
function onRowFocus(row: RunRow): void {
focused.value = row.sid;
spokenAt.value = Date.now();
}
function openRow(row: RunRow, focus: "composer" | "card", event: Event): void {
const target = event.currentTarget;
emit("close");
void runs.open(row, { focus, invoker: target instanceof HTMLElement ? target : null });
}
async function openFull(row: RunRow): Promise<void> {
emit("close");
await runs.open(row);
store.moveTo({ kind: "full" });
void router.push({ name: "assistant", params: { chatId: row.sid } }).catch(() => undefined);
}
function rowButtons(): HTMLElement[] {
return [
...(list.value?.querySelectorAll<HTMLElement>('[data-test="assistant-runs-row-open"]') ?? []),
];
}
function moveFocus(event: KeyboardEvent, buttons: HTMLElement[], index: number): void {
const last = buttons.length - 1;
const steps: Record<string, number> = {
ArrowDown: Math.min(index + 1, last),
ArrowUp: Math.max(index - 1, 0),
Home: 0,
End: last,
};
const next = steps[event.key];
if (next === undefined) return;
event.preventDefault();
buttons[next]?.focus();
}
function onKeydown(event: KeyboardEvent): void {
const buttons = rowButtons();
const index = buttons.indexOf(event.target as HTMLElement);
if (index < 0) return;
if (event.key !== "Delete") return moveFocus(event, buttons, index);
const row = ownRows.value[index];
if (!row || !isFinished(row)) return;
event.preventDefault();
runs.clear(row.sid);
buttons[index + 1]?.focus();
}
onBeforeUnmount(() => clearInterval(timer));
</script>
@@ -0,0 +1,68 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, ref } from "vue";
import { vi } from "vitest";
import type { AssistantRuns, RunRow } from "@/composables/assistant";
/** A row as the list builds it; specs name only what they test. */
export function runRow(sid: string, over: Partial<RunRow> = {}): RunRow {
return {
sid,
org: "default",
title: `Chat ${sid}` as RunRow["title"],
group: "answering",
status: "working",
agent: null,
at: 1_000,
startedAt: 1_000,
turnId: `t-${sid}`,
request: null,
expiresAt: null,
extendsLeft: null,
consent: false,
otherOrg: false,
stale: false,
...over,
};
}
/** The runtime's list with writable rows, so a component spec drives it directly. */
export function fakeRuns(initial: RunRow[] = []) {
const rows = ref<RunRow[]>(initial);
const decisions = ref<RunRow[]>([]);
const active = ref(0);
const requested = ref(false);
const runs = {
rows: computed(() => rows.value),
rail: computed(() => rows.value),
otherOrg: computed(() => rows.value.filter((row) => row.otherOrg)),
cap: computed(() => 3),
active: computed(() => active.value),
needsCount: computed(() => rows.value.filter((row) => row.group === "needs_you").length),
decisions: computed(() => decisions.value),
openRequested: requested,
requestOpen: vi.fn(async () => void (requested.value = true)),
takeOpenRequest: vi.fn(() => void (requested.value = false)),
clear: vi.fn(),
clearFinished: vi.fn(),
stop: vi.fn(async () => undefined),
open: vi.fn(async () => undefined),
light: () => null,
dispose: () => undefined,
} satisfies AssistantRuns;
return { runs, rows, decisions, active };
}
@@ -0,0 +1,76 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { gt, raw } from "@/types/i18n";
import { rowSignal, runAgent, runSignal, runStatus, runsSummary, runTitle } from "./runsText";
import { runRow } from "./runsTestKit";
const counts = (over = {}) => ({ needs: 0, answering: 0, failed: 0, ready: 0, ...over });
describe("runsText", () => {
it("picks one signal by priority: needs you, failed, ready, working", () => {
expect(runSignal(counts({ needs: 1, failed: 2, ready: 3, answering: 4 }))).toBe("attention");
expect(runSignal(counts({ failed: 2, ready: 3 }))).toBe("failed");
expect(runSignal(counts({ ready: 3, answering: 4 }))).toBe("unread");
expect(runSignal(counts({ answering: 4 }))).toBe("working");
});
it("gives a row the signal of its group", () => {
expect(rowSignal(runRow("a", { group: "needs_you" }))).toBe("attention");
expect(rowSignal(runRow("a", { group: "failed" }))).toBe("failed");
expect(rowSignal(runRow("a", { group: "ready" }))).toBe("unread");
expect(rowSignal(runRow("a"))).toBe("working");
});
it("words the summary in the order asked, dropping zero parts (PO B53)", () => {
const all = counts({ needs: 2, answering: 1 });
expect(runsSummary(all, ["answering", "needs"], gt)).toBe("1 answering · 2 need you");
expect(runsSummary(all, ["needs", "answering"], gt)).toBe("2 need you · 1 answering");
expect(runsSummary(counts({ needs: 1 }), ["answering", "needs"], gt)).toBe("1 needs you");
expect(runsSummary(counts(), ["answering", "needs"], gt)).toBe("");
});
it("never leaves a row without a title", () => {
expect(runTitle(runRow("a"), gt)).toBe("Chat a");
expect(runTitle(runRow("a", { title: null }), gt)).toBe("New chat");
expect(runTitle(runRow("a", { title: null, otherOrg: true, org: "acme" }), gt)).toBe(
"A chat in acme",
);
});
it("names the agent by its job, with the product for the default one", () => {
expect(runAgent(runRow("a", { agent: "sre-rca" }), "O2", gt)).toBe("Investigator");
expect(runAgent(runRow("a"), "O2", gt)).toBe("O2");
});
it("words each status with what belongs to it", () => {
expect(runStatus(runRow("a", { startedAt: 1_000 }), 13_000, gt)).toContain("Answering · ");
expect(runStatus(runRow("a", { startedAt: null }), 13_000, gt)).toBe("Answering");
expect(runStatus(runRow("a", { status: "needsApproval", expiresAt: 76_000 }), 1_000, gt)).toBe(
raw("Needs your approval · 1:15"),
);
expect(runStatus(runRow("a", { status: "needsApproval" }), 1_000, gt)).toBe(
"Needs your approval",
);
expect(runStatus(runRow("a", { status: "elsewhere" }), 1_000, gt)).toBe(
"Answering in another tab",
);
expect(runStatus(runRow("a", { status: "reconnecting" }), 1_000, gt)).toBe("Reconnecting");
expect(runStatus(runRow("a", { status: "stopped" }), 1_000, gt)).toContain("Stopped · ");
});
});
@@ -0,0 +1,80 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { RunRow, RunsCounts } from "@/composables/assistant";
import { approvalClock, elapsedText, runAgentKey } from "@/composables/assistant";
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import { formatOrgTime } from "@/utils/formatters";
import type { AssistantSignal } from "../common/AssistantStatusDot.types";
/** The signal a row, or a button speaking for several, shows. */
export function runSignal(counts: RunsCounts): AssistantSignal {
if (counts.needs) return "attention";
if (counts.failed) return "failed";
if (counts.ready) return "unread";
return "working";
}
/** The one mark of a row; a stopped chat has no failure to show. */
export function rowSignal(row: RunRow): AssistantSignal {
if (row.group === "needs_you") return "attention";
if (row.group === "failed") return "failed";
return row.group === "ready" ? "unread" : "working";
}
/** Non-zero parts in the order given, joined the way the list reads them. */
export function runsSummary(
counts: RunsCounts,
order: (keyof RunsCounts)[],
t: TranslateFn,
): I18nText {
const parts = order
.filter((part) => counts[part] > 0)
.map((part) => t(`assistant.runs.summary.${part}`, { count: counts[part] }, counts[part]));
return raw(parts.join(" · "));
}
export function runTitle(row: RunRow, t: TranslateFn): I18nText {
if (row.title) return row.title;
return row.otherOrg
? t("assistant.runs.chatInOrg", { org: raw(row.org) })
: t("assistant.thread.newChatTitle");
}
export function runAgent(row: RunRow, product: string, t: TranslateFn): I18nText {
return t(`assistant.runs.agent.${runAgentKey(row.agent)}`, { product });
}
/** The run-status words of a row, with the elapsed time, countdown or finish time that belongs to them. */
export function runStatus(row: RunRow, now: number, t: TranslateFn): I18nText {
const word = (status: I18nText, elapsed: string) =>
t("assistant.turn.withElapsed", { status, elapsed: raw(elapsed) });
switch (row.status) {
case "working":
return row.startedAt === null
? t("assistant.runs.state.answering")
: t("assistant.runs.state.working", { elapsed: elapsedText(now - row.startedAt, t) });
case "needsApproval": {
const status = t("assistant.runs.state.needsApproval");
return row.expiresAt === null ? status : word(status, approvalClock(row.expiresAt - now));
}
case "reconnecting":
case "elsewhere":
return t(`assistant.runs.state.${row.status}`);
default:
return word(t(`assistant.runs.state.${row.status}`), formatOrgTime(row.at));
}
}
@@ -22,12 +22,14 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw } from "@/types/i18n";
import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant";
import { fakeRuns, runRow } from "../runs/runsTestKit";
import AssistantHeaderButton from "./AssistantHeaderButton.vue";
import { closeLockedPanel, useLockedPanel } from "./lockedPanel";
const { fake, openAssistant, track, toast, lg } = vi.hoisted(() => ({
const { fake, openAssistant, track, toast, lg, otherRuns } = vi.hoisted(() => ({
lg: { value: true },
fake: { current: null as any },
otherRuns: { current: null as any },
openAssistant: vi.fn(async () => ({ ok: true })),
track: vi.fn(),
toast: vi.fn(),
@@ -50,6 +52,7 @@ vi.mock("@/composables/assistant", async (importOriginal) => ({
useAssistantStore: () => fake.current.store,
useAssistantCapability: () => fake.current.handle,
useAssistantHistory: () => fake.current.history,
useAssistantRuns: () => otherRuns.current,
openAssistant,
}));
vi.mock("@/services/assistantAnalytics", async (importOriginal) => ({
@@ -61,6 +64,10 @@ vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({
toast,
}));
beforeEach(() => {
otherRuns.current = fakeRuns().runs;
});
function setup(
gate: GateCode = "available",
over: Partial<AssistantCapability> = {},
@@ -373,6 +380,47 @@ describe("AssistantHeaderButton light", () => {
});
});
describe("AssistantHeaderButton other orgs (PO B51)", () => {
const other = (over = {}) => runRow("x", { otherOrg: true, org: "acme", ...over });
it("counts another org's chats in the light and lists them on click, never opening one", async () => {
otherRuns.current = fakeRuns([other()]).runs;
const { wrapper } = setup();
expect(button(wrapper).attributes("data-state")).toBe("working");
await button(wrapper).trigger("click");
await flushPromises();
expect(otherRuns.current.requestOpen).toHaveBeenCalled();
expect(openAssistant).not.toHaveBeenCalled();
});
it("shows a waiting decision of another org as needs you, with its count", () => {
const waiting = other({ group: "needs_you", status: "needsApproval", request: "x" });
otherRuns.current = fakeRuns([waiting]).runs;
const { wrapper } = setup();
expect(button(wrapper).attributes("data-state")).toBe("needs_you");
expect(button(wrapper).attributes("aria-label")).toContain("1 waiting");
});
it("leaves a light of this org's chats alone", async () => {
otherRuns.current = fakeRuns([other()]).runs;
const { wrapper } = setup("available", {}, { state: "working", sid: "here" });
await button(wrapper).trigger("click");
await flushPromises();
expect(otherRuns.current.requestOpen).not.toHaveBeenCalled();
expect(openAssistant).toHaveBeenCalled();
});
it("counts one decision once, however many chats wait on it", async () => {
const waiting = { group: "needs_you", status: "needsApproval", request: "c" } as const;
otherRuns.current = fakeRuns([
runRow("a", waiting),
runRow("b", { ...waiting, request: "d" }),
]).runs;
const { wrapper } = setup("available", {}, { state: "needs_you", sid: "a" });
expect(button(wrapper).attributes("aria-label")).toContain("2 waiting");
});
});
describe("AssistantHeaderButton announcements and toasts", () => {
const region = (wrapper: any) =>
wrapper.get('[data-test="assistant-header-status-region"]').text();
@@ -87,6 +87,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="region"
data-test="assistant-header-status-region"
/>
<AssistantDecisionNotice
v-if="shortcutId"
@announce="(text) => region?.announce(text)"
@hidden="buttonEl()?.focus()"
/>
<ODialog
v-if="lgUp"
:open="!!dialogGate"
@@ -176,6 +181,8 @@ interface HeaderView {
signal: AssistantSignal | null;
count?: number;
badge: "locked" | "notSetUp" | null;
/** Only another org's chats lit it. */
otherOrg?: boolean;
/** The pitch or setup card a click opens instead of the container. */
dialog: GateReason | null;
}
@@ -204,6 +211,7 @@ import { provideAssistantSurface } from "@/components/ai-assistant/common/assist
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import AssistantStatusDot from "@/components/ai-assistant/common/AssistantStatusDot.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import AssistantDecisionNotice from "@/components/ai-assistant/runs/AssistantDecisionNotice.vue";
import AssistantGateState, {
gateLearnOpen,
type GateCta,
@@ -216,6 +224,7 @@ import {
useAssistantName,
useAssistantOpenChord,
useAssistantPlacement,
useAssistantRuns,
useAssistantStore,
} from "@/composables/assistant";
import useBreakpoint from "@/composables/useBreakpoint";
@@ -241,6 +250,7 @@ const { t } = useI18nTyped();
const { product } = useAssistantName();
const store = useAssistantStore();
const history = useAssistantHistory();
const runs = useAssistantRuns();
const capability = useAssistantCapability();
const placement = useAssistantPlacement(store);
const lockedPanel = useLockedPanel();
@@ -338,7 +348,7 @@ function lightView(): HeaderView {
const p = { product };
const open = visible.value;
if (light.state === "needs_you") {
const waiting = mine.value.filter(needsYou).length;
const waiting = runs.needsCount.value || mine.value.filter(needsYou).length;
const session = light.sid ? store.state.sessions[light.sid] : undefined;
const consent = session?.turn.state === "awaiting_consent" || !!session?.pendingConsent;
return {
@@ -398,11 +408,34 @@ function lightView(): HeaderView {
signal: "unread",
};
}
const others = otherOrgView();
if (others) return others;
if (light.state === "open" || open)
return plain("open", t("assistant.header.nameOpen", p), t("assistant.header.stateOpen", p));
return plain("idle", t("assistant.header.tooltip", p), t("assistant.header.tooltip", p));
}
// Another org's chats count in the light; a click lists them, never opens them in place (PO B51).
function otherOrgView(): HeaderView | null {
const rows = runs.otherOrg.value;
if (!rows.length) return null;
const p = { product };
const waiting = rows.filter((row) => row.group === "needs_you").length;
if (waiting)
return {
...plain(
"needs_you",
t("assistant.header.needsYouCountName", { ...p, count: waiting }),
t("assistant.header.stateNeedsApproval"),
),
signal: "attention",
count: waiting,
otherOrg: true,
};
const name = t("assistant.header.answeringName", p);
return { ...plain("working", name, name), signal: "working", otherOrg: true };
}
function buttonEl(): HTMLElement | null {
return (button.value?.$el as HTMLElement | undefined) ?? null;
}
@@ -424,6 +457,7 @@ function onClick(): void {
}
return;
}
if (current.otherOrg) return void runs.requestOpen(buttonEl());
openOnLitSession(buttonEl());
}
@@ -505,26 +539,20 @@ function toastHidden(): void {
for (const session of mine.value) {
const turnId = session.turnMeta.turnId;
if (!turnId || isShown(session.sid)) continue;
const kind = needsYou(session)
? "needs_you"
: session.unread && session.turn.state === "done"
? "ready"
: null;
// A waiting chat speaks through the decision notice, so only a ready answer toasts.
const kind = session.unread && session.turn.state === "done" ? "ready" : null;
if (!kind || toasted.has(`${turnId}:${kind}`)) continue;
toasted.add(`${turnId}:${kind}`);
showToast(session.sid, kind);
}
}
function showToast(sid: string, kind: "ready" | "needs_you"): void {
function showToast(sid: string, kind: "ready"): void {
let opened = false;
const title = { title: titleOf(sid) };
toast({
variant: "default",
message:
kind === "ready"
? t("assistant.header.toastReady", title)
: t("assistant.header.toastNeedsYou", title),
message: t("assistant.header.toastReady", title),
timeout: HEADER_TOAST_MS,
mask: true,
live: "off",
@@ -24,6 +24,7 @@ import { raw } from "@/types/i18n";
import { fakeAssistant, fakeRouter, fakeVuex, hintBus } from "../__tests__/fakeAssistant";
import { historyStubs } from "../__tests__/historyStubs";
import { fakeRuns } from "../runs/runsTestKit";
import AssistantShell, {
SHELL_ROUTE_CONTEXT,
STEP_ANNOUNCE_MS,
@@ -742,6 +743,35 @@ describe("WP-06 conversation wiring", () => {
expect(label(again)).toMatch(/^Retry in 20 s/);
});
it("opens the running chats from a member's run-limit refusal", async () => {
const { runs } = fakeRuns();
setRuntimeHandle({
runs,
lifecycle: {
signedOutElsewhere: computed(() => false),
otherAccountSignedIn: computed(() => false),
turnLock: () => null,
},
} as never);
const refused = live({
state: "refused",
error: {
code: "run_limit_member",
phase: "pre_admission",
retryable: false,
action: "show_running",
limit: 3,
},
});
await flushPromises();
const action = refused.wrapper.get('[data-test="assistant-turn-outcome-action"]');
expect(action.text()).toBe("Show running chats");
await action.trigger("click");
await flushPromises();
setRuntimeHandle(null);
expect(runs.requestOpen).toHaveBeenCalled();
});
it("keeps focus on Stop it when the cancel fails, and never takes focus the reader moved (V7)", async () => {
const busy = {
code: "turn_busy",
@@ -964,10 +994,14 @@ describe("AssistantShell decision cards", () => {
args: { items: [{ resource_type: "alert", name: "checkout-latency-old", id: "a1" }] },
};
async function mountWithCard(card: "approval" | "consent" | "none" = "approval", open = true) {
async function mountWithCard(
card: "approval" | "consent" | "none" = "approval",
open = true,
capability: Partial<AssistantCapability> = {},
) {
localStorage.clear();
sessionStorage.clear();
fake.current = fakeAssistant("available");
fake.current = fakeAssistant("available", capability);
const store = fake.current.store;
const { approvalOf } = await import("@/composables/assistant/reducer");
const approval = { ...approvalOf(DELETE), id: "t1:1" };
@@ -1028,6 +1062,16 @@ describe("AssistantShell decision cards", () => {
expect(hidden.wrapper.find('[data-test="assistant-approval-card"]').exists()).toBe(false);
});
it("gates the card on a server without split budgets, and lets it through with them", async () => {
const older = await mountWithCard();
expect(older.wrapper.find('[data-test="assistant-approval-gated"]').exists()).toBe(true);
expect(older.wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false);
older.wrapper.unmount();
const newer = await mountWithCard("approval", true, { approvalExtendBudgets: true });
expect(newer.wrapper.find('[data-test="assistant-approval-gated"]').exists()).toBe(false);
expect(newer.wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(true);
});
it("posts the card's decision through the store, and Reply puts the composer in reply mode", async () => {
const { wrapper, store, sid } = await mountWithCard();
const decide = vi.spyOn(store, "decideApproval").mockResolvedValue({} as never);
@@ -1225,6 +1269,19 @@ describe("AssistantShell history states", () => {
expect(wrapper.find('[data-test="assistant-shell-elsewhere-stop"]').exists()).toBe(true);
});
it("says Reconnecting, never another tab, until a live owner shows (PO B3)", async () => {
const { wrapper, store } = setup();
const sid = open(store);
const elsewhere = vi.spyOn(fake.current.history, "elsewhere");
elsewhere.mockReturnValue({ mode: "polling", evidence: false, approval: false, turnId: "t" });
store.state.sessions[sid].turn = { state: "responding.writing" } as never;
store.state.sessions[sid].turnMeta.ownedByThisTab = false;
await flushPromises();
expect(wrapper.get('[data-test="assistant-shell-elsewhere-status"]').text()).toBe(
"Reconnecting",
);
});
it("says history is unavailable with Retry while the cached transcript stays", async () => {
const { wrapper, store } = setup();
const sid = open(store);
@@ -46,6 +46,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:container="container"
>
<template #container-actions>
<AssistantRunsButton />
<slot name="container-actions" />
<template v-if="placement !== 'window'">
<AssistantIconButton
@@ -94,6 +95,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OSkeleton v-if="state === 'loading'" type="text" class="w-40" />
<span class="flex-1" />
<div class="flex shrink-0 items-center gap-1">
<AssistantRunsButton v-if="state !== 'gate'" />
<slot name="container-actions" />
<template v-if="placement !== 'window'">
<AssistantIconButton
@@ -364,6 +366,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:multi-org="(vuex.state.organizations?.length ?? 0) > 1"
:sending="approvalSending"
:extendable="capability.approvalExtend === true"
:gated="capability.approvalExtendBudgets !== true"
@decide="onDecide"
@reply="onReply"
@extend="onExtend"
@@ -747,6 +750,7 @@ import {
useAssistantCredits,
useAssistantName,
useAssistantHistory,
useAssistantRuns,
useAssistantStarters,
useAssistantStore,
useCreditNotice,
@@ -782,6 +786,8 @@ import { raw, useI18nTyped } from "@/types/i18n";
import { copyToClipboard } from "@/utils/clipboard";
import { formatCount, formatOrgIsoDay } from "@/utils/formatters";
import AssistantRunsButton from "@/components/ai-assistant/runs/AssistantRunsButton.vue";
import AssistantLayoutMenu from "./AssistantLayoutMenu.vue";
import AssistantStatusRegion from "./AssistantStatusRegion.vue";
import { closeLockedPanel, useLockedPanel } from "./lockedPanel";
@@ -815,6 +821,7 @@ const { product } = useAssistantName();
const store = useAssistantStore();
const history = useAssistantHistory();
const capability = useAssistantCapability();
const runs = useAssistantRuns();
const lockedPanel = useLockedPanel();
const routeContext = inject(SHELL_ROUTE_CONTEXT, null);
const route = useRoute();
@@ -932,11 +939,13 @@ const elsewhere = computed(() => {
const view = history.elsewhere(current.sid);
const mode = view?.mode ?? "hint";
const line =
mode === "polling"
? t("assistant.shell.answeringElsewherePolling")
: mode === "stalled"
? t("assistant.shell.stillRunningRefresh")
: t("assistant.shell.answeringElsewhere");
mode === "stalled"
? t("assistant.shell.stillRunningRefresh")
: view?.evidence === false
? t("assistant.runs.state.reconnecting")
: mode === "polling"
? t("assistant.shell.answeringElsewherePolling")
: t("assistant.shell.answeringElsewhere");
return {
line,
stalled: mode === "stalled",
@@ -1248,6 +1257,7 @@ const approvalsHost: AssistantApprovalsHost = {
stop: (sid) => void stopHeldTurn(sid),
undo: (sid, ledgerId) =>
assistantApprovals()?.undo(sid, ledgerId) ?? Promise.resolve({ ok: false, reason: "failed" }),
gated: () => capability.value.approvalExtendBudgets !== true,
};
provide(assistantApprovalsHostKey, approvalsHost);
@@ -1332,6 +1342,7 @@ function announcementText(key: string, params: Record<string, unknown>): ReturnT
role: role.value,
persistence: capability.value.persistence,
signedIn: signedIn.value,
gated: capability.value.approvalExtendBudgets !== true,
});
if (!action) return t(`assistant.announce.${state}`, { title: text });
const label = outcomeActionLabel(action, turn.error.code, role.value, t, te);
@@ -1377,6 +1388,8 @@ function onOutcomeAction(sid: string, action: OutcomeAction, outcome: TurnOutcom
case "stop_it":
void stopBusy(sid).then((stopped) => stopped && focusAfterRequest(started));
return;
case "show_running":
return void runs.requestOpen(started instanceof HTMLElement ? started : null);
case "sign_in":
return signIn();
case "retry_save":
@@ -26,7 +26,10 @@ import type { AssistantError, AssistantErrorCode } from "@/composables/assistant
import { setRuntimeHandle } from "@/composables/assistant/runtimeHandle";
import { fakeRouter } from "../__tests__/fakeAssistant";
import AssistantTurnOutcome, { type AssistantTurnOutcomeProps } from "./AssistantTurnOutcome.vue";
import AssistantTurnOutcome, {
outcomeActionOf,
type AssistantTurnOutcomeProps,
} from "./AssistantTurnOutcome.vue";
vi.mock("@/components/ai-assistant/gate/AssistantAskAdminPopover.vue", () => ({
__esModule: true,
@@ -187,6 +190,30 @@ const LEDGER: [AssistantErrorCode, "failed" | "refused", string, string | null,
];
describe("AssistantTurnOutcome", () => {
it("offers Ask again on an expired card only where the card could be approved", () => {
const opts = { role: "admin", persistence: true, signedIn: false } as const;
expect(outcomeActionOf("expired", undefined, opts)).toBe("ask_again");
expect(outcomeActionOf("expired", undefined, { ...opts, gated: true })).toBeNull();
});
it("states the run limit with the server's own number, and offers the list", () => {
const wrapper = mountOutcome({
kind: "refused",
error: {
code: "run_limit_member",
phase: "pre_admission",
retryable: false,
action: "show_running",
limit: 5,
} as never,
});
expect(title(wrapper)).toBe(
"5 of your chats are running or waiting for you. Your message wasn't sent.",
);
expect(wrapper.text()).toContain("Stop one, or send when one ends.");
expect(wrapper.text()).toContain("Show running chats");
});
it.each(LEDGER)(
"renders %s with its title and at most one action, by role",
async (code, kind, text, admin, member) => {
@@ -333,11 +333,11 @@ export function outcomeActionLabel(
export function outcomeActionOf(
kind: OutcomeKind,
error: AssistantError | undefined,
opts: { role: "admin" | "member"; persistence: boolean; signedIn: boolean },
opts: { role: "admin" | "member"; persistence: boolean; signedIn: boolean; gated?: boolean },
): OutcomeAction | null {
if (kind === "stopped") return "continue";
if (kind === "doneNotSaved") return opts.persistence ? "retry_save" : null;
if (kind === "expired") return "ask_again";
if (kind === "expired") return opts.gated ? null : "ask_again";
if (kind === "restored" || kind === "cancelledElsewhere" || !error) return null;
if (error.code === "session_expired" && opts.signedIn) return "retry";
if (error.action === "none" || error.action === "send_again") return null;
@@ -347,9 +347,10 @@ export function outcomeActionOf(
</script>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from "vue";
import { computed, defineAsyncComponent, inject, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { assistantApprovalsHostKey } from "@/components/ai-assistant/approvals/approvals.types";
import AssistantErrorDetails from "@/components/ai-assistant/common/AssistantErrorDetails.vue";
import { billingHrefOf } from "@/components/ai-assistant/credits/AssistantCreditManage.vue";
import { explorerLabel } from "@/components/ai-assistant/evidence/evidenceText";
@@ -499,6 +500,7 @@ const readonlyLine = computed<I18nText>(() =>
: title.value,
);
const approvalsHost = inject(assistantApprovalsHostKey, null);
const action = computed<OutcomeAction | null>(
() =>
creditCopy.value?.action ??
@@ -506,6 +508,7 @@ const action = computed<OutcomeAction | null>(
role: props.role,
persistence: props.persistence,
signedIn: props.signedIn,
gated: approvalsHost?.gated?.() === true,
}),
);
const billingHref = computed(() =>
@@ -0,0 +1,176 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick, reactive } from "vue";
import {
BACKGROUND_EXTEND_AT_MS,
KEEP_ALIVE_RETRY_MS,
createApprovalKeepAlive,
type ApprovalKeepAliveDeps,
} from "./approvalKeepAlive";
import type { AssistantState } from "./assistantStore.types";
const START = 1_000_000;
function fakeLocks(heldNames: string[] = []) {
const held = new Set(heldNames);
return {
held,
query: vi.fn(async () => ({ held: [...held].map((name) => ({ name })), pending: [] })),
request: vi.fn(async (name: string, opts: unknown, cb?: unknown) => {
const callback = (cb ?? opts) as (lock: unknown) => Promise<unknown>;
const shared = (opts as { mode?: string }).mode === "shared";
if (!shared && held.has(name)) return callback(null);
held.add(name);
await callback({ name });
held.delete(name);
return undefined;
}),
};
}
function setup(over: Partial<ApprovalKeepAliveDeps> = {}, expiresAt = START + 300_000) {
const state = reactive({
sessions: {
s1: {
sid: "s1",
pendingApproval: { id: "c1", expiresAt, askedIn: "here" },
},
},
}) as unknown as AssistantState;
const returns: (() => void)[] = [];
const pending = () => state.sessions.s1.pendingApproval!;
const extend = vi.fn(async () => {
pending().expiresAt = (pending().expiresAt as number) + 300_000;
return "extended" as const;
});
const reconcile = vi.fn(async () => undefined);
const keepAlive = createApprovalKeepAlive({
state: () => state,
shownHere: () => [],
budgets: () => true,
locks: fakeLocks() as unknown as ApprovalKeepAliveDeps["locks"],
extend,
reconcile,
now: () => Date.now(),
onReturn: (cb) => {
returns.push(cb);
return () => undefined;
},
...over,
});
return { state, pending, extend, reconcile, keepAlive, returns };
}
const advance = (ms: number) => vi.advanceTimersByTimeAsync(ms);
describe("createApprovalKeepAlive", () => {
let disposers: (() => void)[] = [];
const track = <T extends { keepAlive: { dispose(): void } }>(made: T) => {
disposers.push(() => made.keepAlive.dispose());
return made;
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(START);
disposers = [];
});
afterEach(() => {
disposers.forEach((dispose) => dispose());
vi.useRealTimers();
});
it("extends from the background budget only once 90 s or less remain", async () => {
const { extend } = track(setup());
await advance(300_000 - BACKGROUND_EXTEND_AT_MS - 1_000);
expect(extend).not.toHaveBeenCalled();
await advance(1_000);
expect(extend).toHaveBeenCalledTimes(1);
expect(extend).toHaveBeenCalledWith("s1", "c1");
});
it("re-arms from the server's new expiry and stops once the budget is spent", async () => {
const { extend, pending } = track(setup());
await advance(300_000 - BACKGROUND_EXTEND_AT_MS);
expect(extend).toHaveBeenCalledTimes(1);
extend.mockImplementation(async () => "spent" as never);
await advance(300_000);
expect(extend).toHaveBeenCalledTimes(2);
expect(pending().expiresAt).toBe(START + 600_000);
await advance(600_000);
expect(extend).toHaveBeenCalledTimes(2);
});
it("posts nothing on a server without split budgets (PO A14)", async () => {
const { extend } = track(setup({ budgets: () => false }));
await advance(400_000);
expect(extend).not.toHaveBeenCalled();
});
it("skips the extend while this tab shows the chat, and reads the card when it starts showing", async () => {
const { extend, reconcile } = track(setup({ shownHere: () => ["s1"] }));
await nextTick();
expect(reconcile).toHaveBeenCalledWith("s1");
await advance(400_000);
expect(extend).not.toHaveBeenCalled();
});
it("skips the extend while another tab holds the shown lock", async () => {
const locks = fakeLocks(["o2-assistant-shown:s1"]);
const { extend } = track(setup({ locks: locks as never }));
await advance(300_000 - BACKGROUND_EXTEND_AT_MS + KEEP_ALIVE_RETRY_MS);
expect(extend).not.toHaveBeenCalled();
locks.held.delete("o2-assistant-shown:s1");
await advance(KEEP_ALIVE_RETRY_MS);
expect(extend).toHaveBeenCalledTimes(1);
});
it("never extends from a tab that did not get the extend lock", async () => {
const locks = fakeLocks(["o2-assistant-extend:c1"]);
const { extend } = track(setup({ locks: locks as never }));
await advance(400_000);
expect(extend).not.toHaveBeenCalled();
});
it("re-reads the card on taking the lock and on every return", async () => {
const { reconcile, returns } = track(setup());
await advance(0);
expect(reconcile).toHaveBeenCalledTimes(1);
returns[0]();
expect(reconcile).toHaveBeenCalledTimes(2);
});
it("without Web Locks extends only the card that came on this tab's own stream", async () => {
const own = track(setup({ locks: null }));
await advance(300_000 - BACKGROUND_EXTEND_AT_MS);
expect(own.extend).toHaveBeenCalledTimes(1);
const other = track(setup({ locks: null }));
other.pending().askedIn = "other_tab";
await advance(600_000);
expect(other.extend).not.toHaveBeenCalled();
});
it("forgets a decided card, so nothing is posted for it", async () => {
const { state, extend } = track(setup());
state.sessions.s1.pendingApproval = null;
await nextTick();
await advance(400_000);
expect(extend).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,198 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { watch } from "vue";
import type { AssistantState, Sid } from "./assistantStore.types";
import type { BackgroundExtend } from "./useAssistantApprovals";
/** More than one wake-up of a hidden tab under the browser's timer throttling (PO B57). */
export const BACKGROUND_EXTEND_AT_MS = 90_000;
/** A tab that could not act when due looks again this soon. */
export const KEEP_ALIVE_RETRY_MS = 15_000;
export interface ApprovalKeepAliveDeps {
state(): AssistantState;
/** The chats a visible container of this tab shows, while the page itself is visible. */
shownHere(): Sid[];
/** The server splits the budgets; without it nothing is ever posted out of view (PO A14). */
budgets(): boolean;
locks: Pick<LockManager, "request" | "query"> | null;
extend(sid: Sid, approvalId: string): Promise<BackgroundExtend>;
reconcile(sid: Sid): Promise<void>;
now(): number;
/** Fires on `visibilitychange` to visible, `pageshow` and `online`. */
onReturn(cb: () => void): () => void;
}
export interface ApprovalKeepAlive {
dispose(): void;
}
interface Card {
sid: Sid;
id: string;
timer?: ReturnType<typeof setTimeout>;
owner: boolean;
requesting: boolean;
spent: boolean;
extending: boolean;
release?: () => void;
}
/** Keeps a pending card alive while no tab of this browser shows its chat: one owner, ≤ 90 s left, background budget only. */
export function createApprovalKeepAlive(deps: ApprovalKeepAliveDeps): ApprovalKeepAlive {
const cards = new Map<string, Card>();
const shownLocks = new Map<Sid, () => void>();
const keyOf = (sid: Sid, id: string) => `${sid}\u0001${id}`;
const pendingOf = (card: Card) => {
const pending = deps.state().sessions[card.sid]?.pendingApproval;
return pending?.id === card.id ? pending : null;
};
const shownElsewhere = async (sid: Sid) => {
const snapshot = await deps.locks?.query().catch(() => null);
return !!snapshot?.held?.some((lock) => lock.name === `o2-assistant-shown:${sid}`);
};
const holdShown = (sid: Sid) => {
if (!deps.locks || shownLocks.has(sid)) return;
let release: () => void = () => undefined;
const held = new Promise<void>((resolve) => (release = resolve));
shownLocks.set(sid, release);
void deps.locks
.request(`o2-assistant-shown:${sid}`, { mode: "shared" }, () => held)
.catch(() => undefined);
};
const takeOwnership = (card: Card) => {
// Without the Locks API only the tab that got the card on its own stream extends it.
if (!deps.locks) card.owner = pendingOf(card)?.askedIn === "here";
if (card.owner || card.requesting || !deps.locks) return;
card.requesting = true;
let release: () => void = () => undefined;
const held = new Promise<void>((resolve) => (release = resolve));
void deps.locks
.request(`o2-assistant-extend:${card.id}`, { ifAvailable: true }, (lock) => {
card.requesting = false;
if (!lock || !cards.has(keyOf(card.sid, card.id))) return undefined;
card.owner = true;
card.release = release;
void deps.reconcile(card.sid);
return held;
})
.catch(() => {
card.requesting = false;
});
};
const schedule = (card: Card, delay?: number) => {
clearTimeout(card.timer);
const pending = pendingOf(card);
if (!pending || typeof pending.expiresAt !== "number" || card.spent) return;
const due = pending.expiresAt - BACKGROUND_EXTEND_AT_MS - deps.now();
card.timer = setTimeout(() => void tick(card), delay ?? Math.max(0, due));
};
const tick = async (card: Card) => {
const pending = pendingOf(card);
if (!pending || card.spent || card.extending) return;
takeOwnership(card);
if (!deps.budgets() || !card.owner) return schedule(card, KEEP_ALIVE_RETRY_MS);
const shown = deps.shownHere().includes(card.sid) || (await shownElsewhere(card.sid));
if (shown) return schedule(card, KEEP_ALIVE_RETRY_MS);
const left = (pending.expiresAt ?? 0) - deps.now();
if (left > BACKGROUND_EXTEND_AT_MS) return schedule(card);
card.extending = true;
const result = await deps.extend(card.sid, card.id);
card.extending = false;
if (result === "spent") card.spent = true;
if (result === "failed") schedule(card, KEEP_ALIVE_RETRY_MS);
else schedule(card);
};
const forget = (key: string, card: Card) => {
clearTimeout(card.timer);
card.release?.();
cards.delete(key);
};
const syncCards = () => {
const seen = new Set<string>();
for (const session of Object.values(deps.state().sessions)) {
const pending = session.pendingApproval;
if (!pending) continue;
const key = keyOf(session.sid, pending.id);
seen.add(key);
const known = cards.get(key);
if (known) {
schedule(known);
continue;
}
const card: Card = {
sid: session.sid,
id: pending.id,
owner: false,
requesting: false,
spent: false,
extending: false,
};
cards.set(key, card);
takeOwnership(card);
schedule(card);
}
for (const [key, card] of cards) if (!seen.has(key)) forget(key, card);
};
const syncShown = (shown: Sid[], before: Sid[] = []) => {
for (const sid of shown) holdShown(sid);
for (const [sid, release] of shownLocks) {
if (shown.includes(sid)) continue;
release();
shownLocks.delete(sid);
}
// A card a container starts showing is re-read before its countdown is trusted.
for (const sid of shown) if (!before.includes(sid)) void deps.reconcile(sid);
};
const stopCards = watch(
() =>
Object.values(deps.state().sessions)
.map((s) =>
s.pendingApproval
? `${s.sid}:${s.pendingApproval.id}:${s.pendingApproval.expiresAt}`
: "",
)
.join(),
syncCards,
{ immediate: true },
);
const stopShown = watch(() => deps.shownHere(), syncShown, { immediate: true });
const offReturn = deps.onReturn(() => {
for (const card of cards.values()) void deps.reconcile(card.sid);
});
return {
dispose() {
stopCards();
stopShown();
offReturn();
for (const [key, card] of cards) forget(key, card);
for (const release of shownLocks.values()) release();
shownLocks.clear();
},
};
}
@@ -216,6 +216,36 @@ describe("toAssistantError: status beats body", () => {
});
});
it("picks the run-limit copy by the typed scope, never by the message", () => {
const refusal = (body: Record<string, unknown>) =>
toAssistantError({ status: 429, body: { error_code: "turn_limit", ...body } });
const member = refusal({ scope: "user", limit: 5, active: 5, message: "org limit" });
expect(member).toMatchObject({
code: "run_limit_member",
limit: 5,
active: 5,
action: "show_running",
retryable: false,
});
expect(refusal({ scope: "org", retry_after_ms: 4_000 })).toMatchObject({
code: "run_limit_org",
retryAfterMs: 4_000,
action: "retry",
});
expect(refusal({ scope: "org", retry_after: 4 }).retryAfterMs).toBe(4_000);
expect(refusal({ scope: "user" }).code).toBe("turn_limit");
expect(refusal({ scope: "node", limit: 200 }).code).toBe("turn_limit");
expect(refusal({}).code).toBe("turn_limit");
});
it("takes a server that names the run-limit code itself, with the same fallback", () => {
const named = (error_code: string, body: Record<string, unknown> = {}) =>
toAssistantError({ status: 429, body: { error_code, ...body } }).code;
expect(named("run_limit_member", { limit: 3 })).toBe("run_limit_member");
expect(named("run_limit_member")).toBe("turn_limit");
expect(named("run_limit_org")).toBe("run_limit_org");
});
it("reads a 429 without error_code as rate_limited, never turn_limit (BE D4)", () => {
expect(toAssistantError({ status: 429, body: { code: 429, error: "slow down" } }).code).toBe(
"rate_limited",
@@ -45,6 +45,8 @@ export const ASSISTANT_ERROR_CODES = [
"consent_still_required",
"stopped_before_send",
"authorization_unavailable",
"run_limit_member",
"run_limit_org",
"turn_limit",
"rate_limited",
"turn_busy",
@@ -90,6 +92,8 @@ export const ERROR_LEDGER: Record<AssistantErrorCode, ErrorLedgerRow> = {
consent_still_required: { phase: "pre_admission", retryable: false, action: "manage_usage" },
stopped_before_send: { phase: "pre_admission", retryable: true, action: "retry" },
authorization_unavailable: { phase: "pre_admission", retryable: true, action: "retry" },
run_limit_member: { phase: "pre_admission", retryable: false, action: "show_running" },
run_limit_org: { phase: "pre_admission", retryable: true, action: "retry" },
turn_limit: { phase: "pre_admission", retryable: true, action: "retry" },
rate_limited: { phase: "pre_admission", retryable: true, action: "retry" },
turn_busy: { phase: "pre_admission", retryable: false, action: "stop_it" },
@@ -138,6 +142,12 @@ const CLIENT_ONLY_CODES: ReadonlySet<string> = new Set(["approval_elsewhere", "a
const CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
const RUN_LIMIT_CODES: ReadonlySet<string> = new Set([
"turn_limit",
"run_limit_member",
"run_limit_org",
]);
const WIRE_ACTION_SET: ReadonlySet<string> = new Set(WIRE_ACTIONS);
// Read only when the code names no button of its own (`unknown`); every ledger code keeps its row's action.
@@ -202,6 +212,10 @@ interface WireBody {
recoverable?: unknown;
retryable?: unknown;
retry_after?: unknown;
retry_after_ms?: unknown;
scope?: unknown;
limit?: unknown;
active?: unknown;
action?: unknown;
trace_id?: unknown;
turn_id?: unknown;
@@ -272,6 +286,7 @@ function build(
if (sessionId) error.sessionId = sessionId;
if (code === "rate_limited" && typeof body.retry_after === "number")
error.retryAfterMs = body.retry_after * 1000;
if (code === "run_limit_member" || code === "run_limit_org") readRunLimit(error, body);
const reason = code === "provider_error" ? providerReason(body) : null;
if (reason) error.reason = reason;
if (code === "rca_disabled" && (body.reason === "rca_off" || body.reason === "priority_off"))
@@ -315,7 +330,7 @@ function codeOf({ status, body }: ErrorInput): AssistantErrorCode {
if (status === 403) return "permission_denied";
if (isConsentPrompt(status, body)) return "consent_declined";
const named = namedCode(body);
if (named) return named;
if (named) return RUN_LIMIT_CODES.has(named) ? runLimitCode(named, body) : named;
if (status !== undefined) return statusCode(status, body);
return frameCode(body);
}
@@ -336,6 +351,20 @@ function namedCode(body: WireBody): AssistantErrorCode | null {
return null;
}
// The copy follows the typed code or `scope`, never the message; `scope:"node"` and a member refusal without `limit` keep the fallback row (`80` §1.3).
function runLimitCode(named: AssistantErrorCode, body: WireBody): AssistantErrorCode {
if (named === "run_limit_org" || body.scope === "org") return "run_limit_org";
const member = named === "run_limit_member" || body.scope === "user";
return member && typeof body.limit === "number" ? "run_limit_member" : "turn_limit";
}
function readRunLimit(error: AssistantError, body: WireBody): void {
if (typeof body.limit === "number") error.limit = body.limit;
if (typeof body.active === "number") error.active = body.active;
if (typeof body.retry_after_ms === "number") error.retryAfterMs = body.retry_after_ms;
else if (typeof body.retry_after === "number") error.retryAfterMs = body.retry_after * 1000;
}
function statusCode(status: number, body: WireBody): AssistantErrorCode {
const message = textOf(body.message) ?? "";
if (status === 401) return "session_expired";
@@ -349,6 +349,8 @@ export interface PendingApproval {
scanEstimate?: { bytes: number; rows?: number };
/** Epoch ms; absent means no TTL is known (`o2_delete` batch today). */
expiresAt?: number;
/** From the last extend answer; absent until the server has said. */
userExtendsLeft?: number;
askedIn: "here" | "other_tab";
/** False until B-05. */
grantable: boolean;
@@ -523,6 +525,8 @@ export interface AssistantCapability {
attachments?: boolean;
/** `/confirm {id, extend:true}` gives a card more time. */
approvalExtend?: boolean;
/** The server counts `by:"user"` and `by:"background"` apart; approvals are gated without it (PO A14, B96). */
approvalExtendBudgets?: boolean;
/** Each used only when advertised: server search `q`, server pins, browser-chat import. */
historySearch?: boolean;
historyPins?: boolean;
@@ -553,6 +557,8 @@ export type AssistantErrorCode =
| "consent_still_required"
| "stopped_before_send"
| "authorization_unavailable"
| "run_limit_member"
| "run_limit_org"
| "turn_limit"
| "rate_limited"
| "turn_busy"
@@ -600,6 +606,7 @@ export type AssistantErrorAction =
| "ask_again"
| "send_again"
| "change_level"
| "show_running"
| "none";
export interface AssistantError {
@@ -608,6 +615,9 @@ export interface AssistantError {
phase: "pre_admission" | "post_admission" | "read" | "mutation" | "client";
retryable: boolean;
retryAfterMs?: number;
/** A run-limit 429's own cap and count; the copy never assumes 3. */
limit?: number;
active?: number;
action: AssistantErrorAction;
httpStatus?: number;
/** Raw server text, shown in Details only. */
@@ -688,6 +698,8 @@ export type AssistantHint =
origin: "chat" | "rca";
at: number;
}
/** From the tab that owns the stream, while the turn is not terminal; receivers trust it for 5 s. */
| { v: 1; type: "turn_heartbeat"; org: string; sid: string; turnId: string; at: number }
| {
v: 1;
type: "turn_ended";
@@ -725,7 +737,9 @@ export type AssistantHint =
op: "create" | "rename" | "pin" | "delete" | "clear";
at: number;
}
| { v: 1; type: "share_changed"; sid: string; at: number };
| { v: 1; type: "share_changed"; sid: string; at: number }
/** A row cleared or opened in this browser; receivers re-read the cleared set. */
| { v: 1; type: "runs_cleared"; sid: string; at: number };
export interface AssistantStoreApi {
/** Reactive; components read through `computed()`. */
@@ -74,6 +74,15 @@ describe("hint type", () => {
expectTypeOf<AssistantHint["type"]>().not.toEqualTypeOf<"draft_changed">();
});
it("carries the multi-chat hints by id only", () => {
const beat = { v: 1, type: "turn_heartbeat", org: "org1", sid: "s1", turnId: "t1", at };
expect(toHint({ ...beat, title: "secret" })).toEqual(beat);
expect(toHint({ ...beat, turnId: 3 })).toBeNull();
const cleared = { v: 1, type: "runs_cleared", sid: "s1", at };
expect(toHint({ ...cleared, title: "secret" })).toEqual(cleared);
expect(toHint({ ...cleared, sid: 1 })).toBeNull();
});
it("rebuilds a received payload field by field and refuses anything else", () => {
expect(toHint({ ...started, text: "my prompt" })).toEqual(started);
expect(toHint({ ...started, org: { evil: true } })).toBeNull();
+5
View File
@@ -104,6 +104,9 @@ export function toHint(value: unknown): AssistantHint | null {
origin: raw.origin,
at,
};
case "turn_heartbeat":
if (!ids(raw, "org", "sid", "turnId")) return null;
return { v: 1, type: raw.type, org: raw.org, sid: raw.sid, turnId: raw.turnId, at };
case "turn_ended":
if (!ids(raw, "org", "sid", "turnId") || !TURN_STATUSES.has(String(raw.status))) return null;
return {
@@ -316,6 +319,7 @@ function otherHint(raw: Record<string, unknown>, at: number): AssistantHint | nu
at,
};
case "share_changed":
case "runs_cleared":
return ids(raw, "sid") ? { v: 1, type: raw.type, sid: raw.sid, at } : null;
default:
return null;
@@ -339,6 +343,7 @@ function countsAsNew(episodes: Set<string>, hint: AssistantHint): boolean {
if (!("sid" in hint)) return true;
if (hint.type === "approval_resolved") forget(episodes, `approval_pending:${hint.sid}:`);
if (hint.type === "turn_ended") episodes.delete(`turn_started:${hint.sid}:${hint.turnId}`);
if (hint.type === "turn_heartbeat") return false;
if (hint.type !== "approval_pending" && hint.type !== "turn_started") return true;
const id = hint.type === "approval_pending" ? (hint.callId ?? hint.turnId) : hint.turnId;
const key = `${hint.type}:${hint.sid}:${id}`;
@@ -22,7 +22,7 @@ import { raw } from "@/types/i18n";
import { toAssistantError } from "./assistantErrors";
import type { Sid } from "./assistantStore.types";
import { goneElsewhere, type HistoryCtx, type HistoryDeps } from "./historyContext";
import { createElsewhere, type Elsewhere } from "./historyElsewhere";
import { assistantClientId, createElsewhere, type Elsewhere } from "./historyElsewhere";
import {
cardEnding,
ownerOptions,
@@ -79,10 +79,12 @@ export function createHistoryCrossTab(ctx: HistoryCtx, records: HistoryRecords)
running: !!detail.active_turn,
approval: detail.pending_approval === true,
turnId: detail.active_turn_id ?? null,
ownerClientId: detail.owner_client_id ?? null,
};
},
ended: (sid) => void endElsewhere(sid),
gone: (sid) => void goneElsewhere(sid, ctx),
clientId: assistantClientId,
});
const ownsRunning = (sid: Sid) => {
@@ -101,7 +103,11 @@ export function createHistoryCrossTab(ctx: HistoryCtx, records: HistoryRecords)
session.turnMeta.ownedByThisTab = false;
session.turnMeta.turnId = active.turnId;
session.turnMeta.startedAt ??= deps.now();
const view = { turnId: active.turnId, approval: active.approval };
const view = {
turnId: active.turnId,
approval: active.approval,
ownerClientId: active.ownerClientId ?? null,
};
elsewhere.start(sid, view, { polls: ctx.serverMode() });
if (!ctx.serverMode()) void waitForLocalOwner(sid, true);
if (active.card && active.turnId) void holdRestored(sid, active.turnId, active.card);
@@ -244,12 +250,16 @@ export function createHistoryCrossTab(ctx: HistoryCtx, records: HistoryRecords)
};
const offs = [
// `turn_started` alone is no evidence of a live owner (BUG-019), so only a heartbeat hands the chat over.
deps.hints.on("turn_started", (hint) => {
if (elsewhere.views[hint.sid])
elsewhere.start(hint.sid, { approval: false, turnId: hint.turnId });
}),
deps.hints.on("turn_heartbeat", (hint) => {
const session = ctx.session(hint.sid);
if (session && (session.turn.state === "idle" || isTerminal(session.turn)))
mark(hint.sid, { turnId: hint.turnId, approval: false });
else if (session && elsewhere.views[hint.sid])
elsewhere.start(hint.sid, { approval: false, turnId: hint.turnId });
elsewhere.beat(hint.sid);
}),
deps.hints.on("turn_ended", (hint) => void endElsewhere(hint.sid)),
deps.hints.on("approval_pending", (hint) => {
@@ -16,11 +16,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
HEARTBEAT_TRUST_MS,
HINT_STALE_MS,
POLL_FAST_FOR_MS,
POLL_FAST_MS,
POLL_SLOW_MS,
POLL_STOP_MS,
assistantClientId,
createElsewhere,
type ElsewhereDeps,
} from "./historyElsewhere";
@@ -78,6 +80,62 @@ afterEach(() => {
vi.useRealTimers();
});
describe("assistantClientId", () => {
it("stays the same across a reload of this tab", () => {
sessionStorage.clear();
const id = assistantClientId();
expect(id).toBeTruthy();
expect(assistantClientId()).toBe(id);
});
});
describe("the evidence rule (D1 decision 13, PO B3)", () => {
const view = { turnId: "t1", approval: false };
it("has no evidence from a started turn alone", () => {
const { elsewhere } = tab({ clientId: () => "me" });
elsewhere.start("s1", view);
expect(elsewhere.views.s1.evidence).toBe(false);
elsewhere.stop("s1");
});
it("trusts a heartbeat for 5 s, and each beat renews it", async () => {
const { elsewhere } = tab({ clientId: () => "me" });
elsewhere.start("s1", view);
elsewhere.beat("s1");
expect(elsewhere.views.s1.evidence).toBe(true);
await vi.advanceTimersByTimeAsync(HEARTBEAT_TRUST_MS - 1);
elsewhere.beat("s1");
await vi.advanceTimersByTimeAsync(HEARTBEAT_TRUST_MS - 1);
expect(elsewhere.views.s1.evidence).toBe(true);
await vi.advanceTimersByTimeAsync(1);
expect(elsewhere.views.s1.evidence).toBe(false);
elsewhere.stop("s1");
});
it("takes the server's word only when it names another client", async () => {
const poll = vi.fn(async () => ({
running: true,
approval: false,
turnId: "t1",
ownerClientId: "other",
}));
const { elsewhere } = tab({ clientId: () => "me", poll });
elsewhere.start("s1", { ...view, ownerClientId: "me" });
expect(elsewhere.views.s1.evidence).toBe(false);
await vi.advanceTimersByTimeAsync(POLL_FAST_MS);
expect(elsewhere.views.s1.evidence).toBe(true);
elsewhere.stop("s1");
});
it("counts a browser-only chat as evidenced: its hints and lock are all there is", () => {
const { elsewhere } = tab();
elsewhere.start("s1", view, { polls: false });
expect(elsewhere.views.s1.evidence).toBe(true);
elsewhere.stop("s1");
});
});
describe("createElsewhere", () => {
it("polls every 4 s for a minute, then every 15 s, and stops loudly at ten minutes", async () => {
const { elsewhere, poll } = tab();
@@ -23,6 +23,12 @@ export const POLL_FAST_FOR_MS = 60_000;
export const POLL_STOP_MS = 10 * 60_000;
/** Without Web Locks, a chat followed by hints alone is released when no hint has spoken for this long. */
export const HINT_STALE_MS = 150_000;
/** A `turn_heartbeat` is evidence of a live owner for this long (D1 decision 13). */
export const HEARTBEAT_TRUST_MS = 5_000;
const CLIENT_ID_KEY = "o2.assistant.clientId";
let memoryClientId: string | null = null;
/** How this tab follows a turn another tab or device runs. */
export interface ElsewhereView {
@@ -30,6 +36,8 @@ export interface ElsewhereView {
mode: "tail" | "polling" | "stalled" | "hint";
approval: boolean;
turnId: string | null;
/** Positive evidence of a live owner: a recent heartbeat, or the server naming another client. */
evidence?: boolean;
}
export interface ElsewhereDeps {
@@ -38,12 +46,22 @@ export interface ElsewhereDeps {
onVisibility(cb: () => void): () => void;
locks: Pick<LockManager, "request"> | null;
/** One incremental read of the chat; `"gone"` when the server no longer has it. */
poll(sid: Sid): Promise<{ running: boolean; approval: boolean; turnId: string | null } | "gone">;
poll(sid: Sid): Promise<
| {
running: boolean;
approval: boolean;
turnId: string | null;
ownerClientId?: string | null;
}
| "gone"
>;
/** The server's resumable tail for the lock holder; absent on today's server. */
tail?(sid: Sid, signal: AbortSignal): Promise<void>;
ended(sid: Sid): void;
/** The chat was deleted while this tab followed it. */
gone?(sid: Sid): void;
/** This tab's `x-o2-assistant-client-id`. */
clientId?(): string;
}
interface Watcher {
@@ -54,12 +72,21 @@ interface Watcher {
requesting?: boolean;
inFlight: boolean;
readAt: number;
beatTimer?: ReturnType<typeof setTimeout>;
beating: boolean;
ownedElsewhere: boolean;
}
export interface Elsewhere {
views: Readonly<Record<Sid, ElsewhereView>>;
/** `polls: false` follows a browser-only chat by hints and the turn lock alone. */
start(sid: Sid, view: Omit<ElsewhereView, "mode">, opts?: { polls?: boolean }): void;
start(
sid: Sid,
view: Omit<ElsewhereView, "mode" | "evidence"> & { ownerClientId?: string | null },
opts?: { polls?: boolean },
): void;
/** The owning tab's `turn_heartbeat`. */
beat(sid: Sid): void;
stop(sid: Sid): void;
refresh(sid: Sid): void;
/** Whether this tab holds the chat's tail lock, for tests and the one-tail rule. */
@@ -67,11 +94,35 @@ export interface Elsewhere {
dispose(): void;
}
/** This tab's id for `x-o2-assistant-client-id`, kept across a reload so a tab recognises its own turn. */
export function assistantClientId(): string {
try {
const stored = sessionStorage.getItem(CLIENT_ID_KEY);
if (stored) return stored;
const id = crypto.randomUUID();
sessionStorage.setItem(CLIENT_ID_KEY, id);
return id;
} catch {
memoryClientId ??= crypto.randomUUID();
return memoryClientId;
}
}
/** Polls at 4 s, 15 s after a minute, never while hidden, and stops loudly at ten minutes; one tail per chat per browser. */
export function createElsewhere(deps: ElsewhereDeps): Elsewhere {
const views = reactive<Record<Sid, ElsewhereView>>({});
const watchers = new Map<Sid, Watcher>();
const isOtherClient = (owner: string | null | undefined) =>
!!owner && !!deps.clientId && owner !== deps.clientId();
// A browser-only chat has no server owner: the hints and the turn lock that keep it followed are its evidence.
const refreshEvidence = (sid: Sid) => {
const watcher = watchers.get(sid);
if (!watcher || !views[sid]) return;
views[sid].evidence = !watcher.polls || watcher.beating || watcher.ownedElsewhere;
};
const schedule = (sid: Sid) => {
const watcher = watchers.get(sid);
if (!watcher || !watcher.polls || deps.hidden()) return;
@@ -126,6 +177,8 @@ export function createElsewhere(deps: ElsewhereDeps): Elsewhere {
if (result && views[sid]) {
views[sid].approval = result.approval;
views[sid].turnId = result.turnId ?? views[sid].turnId;
watcher.ownedElsewhere = isOtherClient(result.ownerClientId);
refreshEvidence(sid);
}
takeTail(sid);
schedule(sid);
@@ -163,6 +216,7 @@ export function createElsewhere(deps: ElsewhereDeps): Elsewhere {
const watcher = watchers.get(sid);
if (watcher) {
clearTimeout(watcher.timer);
clearTimeout(watcher.beatTimer);
watcher.release?.();
}
watchers.delete(sid);
@@ -189,18 +243,41 @@ export function createElsewhere(deps: ElsewhereDeps): Elsewhere {
start(sid, view, opts = {}) {
const polls = opts.polls ?? true;
const known = watchers.get(sid);
const { approval, turnId, ownerClientId } = view;
if (known && views[sid]) {
views[sid].approval = view.approval;
views[sid].turnId = view.turnId ?? views[sid].turnId;
views[sid].approval = approval;
views[sid].turnId = turnId ?? views[sid].turnId;
if (ownerClientId !== undefined) known.ownedElsewhere = isOtherClient(ownerClientId);
refreshEvidence(sid);
armStale(sid);
return;
}
watchers.set(sid, { startedAt: deps.now(), polls, inFlight: false, readAt: 0 });
views[sid] = { ...view, mode: polls ? "polling" : "hint" };
watchers.set(sid, {
startedAt: deps.now(),
polls,
inFlight: false,
readAt: 0,
beating: false,
ownedElsewhere: isOtherClient(ownerClientId),
});
views[sid] = { approval, turnId, mode: polls ? "polling" : "hint" };
refreshEvidence(sid);
takeTail(sid);
schedule(sid);
armStale(sid);
},
beat(sid) {
const watcher = watchers.get(sid);
if (!watcher) return;
watcher.beating = true;
refreshEvidence(sid);
armStale(sid);
clearTimeout(watcher.beatTimer);
watcher.beatTimer = setTimeout(() => {
watcher.beating = false;
refreshEvidence(sid);
}, HEARTBEAT_TRUST_MS);
},
stop,
refresh(sid) {
const watcher = watchers.get(sid);
@@ -71,6 +71,8 @@ export interface ElsewhereMark {
turnId: string | null;
approval: boolean;
card?: ApprovalBlockData;
/** The client the server says runs the turn, when it tracks one. */
ownerClientId?: string | null;
}
/** Opens a chat from the server or the browser store, and builds what an export folds. */
@@ -156,6 +158,7 @@ export function createHistoryRecords(ctx: HistoryCtx, list: HistoryList) {
elsewhereHooks.mark?.(sid, {
turnId: detail.active_turn_id ?? (card ? turnId : null),
approval: detail.pending_approval === true,
ownerClientId: detail.owner_client_id ?? null,
...(card && { card }),
});
return sid;
+4 -1
View File
@@ -34,7 +34,7 @@ export { useAssistantName } from "./useAssistantName";
export { useAssistantClient } from "./useAssistantClient";
export { parseStream, extractFrames } from "./sseParser";
export { toAssistantError, ASSISTANT_ERROR_CODES } from "./assistantErrors";
export { STALL_MS, turnStatusText } from "./turnStateMachine";
export { STALL_MS, elapsedText, turnStatusText } from "./turnStateMachine";
export { starterPayload, widerPeriodText } from "./starterPayload";
export { useAssistantStarters } from "./useAssistantStarters";
export { groupOf, originOf, useAssistantHistory } from "./useAssistantHistory";
@@ -193,10 +193,13 @@ export {
useTurnLock,
} from "./runtimeHandle";
export { queryRunOf } from "./actions/appliers/query";
export { runAgentKey, runsCounts, shownSids, useAssistantRuns, RUN_CAP_DEFAULT } from "./runs";
export type { AssistantRuns, RunRow, RunStatus, RunsCounts } from "./runs";
export {
DELETE_TTL_MS,
PERMISSION_TTL_MS,
approvalClock,
approvalDuration,
approvalEntity,
approvalHeading,
approvalOutcomeOf,
@@ -28,6 +28,7 @@ import {
APPROVAL_HEARTBEAT_MS,
APPROVAL_STALE_MS,
LIVE_TURN_TTL_MS,
TURN_HEARTBEAT_MS,
STOP_RELAY_MS,
createLifecycle,
readLiveTurns,
@@ -635,6 +636,70 @@ describe("turn mirror and live-turn ledger", () => {
expect(readLiveTurns(USER_HASH)).toEqual([]);
});
it("beats every 2 s while the turn runs, and stops when it ends (PO B1)", async () => {
vi.useFakeTimers();
const { store, hints } = setup();
const sid = running(store);
await nextTick();
const beats = () =>
hints.postFor.mock.calls.filter(([, hint]) => hint.type === "turn_heartbeat").length;
expect(beats()).toBe(1);
await vi.advanceTimersByTimeAsync(TURN_HEARTBEAT_MS * 2);
expect(beats()).toBe(3);
expect(hints.postFor).toHaveBeenCalledWith(
"k1",
expect.objectContaining({ type: "turn_heartbeat", sid, turnId: "t1", org: "org1" }),
);
store.state.sessions[sid].turn = { state: "done", saved: "local" };
await nextTick();
await vi.advanceTimersByTimeAsync(TURN_HEARTBEAT_MS * 3);
expect(beats()).toBe(3);
});
it("re-announces a held card when an extension moves its expiry", async () => {
const { store, hints } = setup();
const sid = running(store);
const session = store.state.sessions[sid];
session.turn = { state: "awaiting_approval" } as never;
session.pendingApproval = { id: "c1", expiresAt: 1_000, askedIn: "here" } as never;
await nextTick();
session.pendingApproval!.expiresAt = 2_000;
await nextTick();
const cards = hints.postFor.mock.calls.filter(([, hint]) => hint.type === "approval_pending");
expect(cards[cards.length - 1][1]).toMatchObject({ expiresAt: 2_000 });
});
it("lists every org's turns another tab runs, with the owner's last beat", () => {
let clock = 1_000;
const { lifecycle, receive } = setup({}, () => clock);
receive({
v: 1,
type: "turn_started",
org: "org2",
sid: "s2",
turnId: "t",
origin: "chat",
at: 1,
});
expect(lifecycle.remoteTurns()).toEqual([
{ sid: "s2", org: "org2", state: "working", beatAt: null },
]);
clock = 2_000;
receive({ v: 1, type: "turn_heartbeat", org: "org2", sid: "s2", turnId: "t", at: 2 });
expect(lifecycle.remoteTurns()[0].beatAt).toBe(2_000);
expect(lifecycle.remoteLight()).toBeNull();
receive({
v: 1,
type: "turn_ended",
org: "org2",
sid: "s2",
turnId: "t",
status: "completed",
at: 3,
});
expect(lifecycle.remoteTurns()).toEqual([]);
});
it("prunes ledger entries older than an hour and merges two tabs' writes", () => {
const now = 10 * LIVE_TURN_TTL_MS;
writeLiveTurn(
+87 -2
View File
@@ -45,6 +45,9 @@ export const APPROVAL_STALE_MS = 150_000;
/** A holding tab that sends no beat back within this long is gone, and the waiting tab cancels the turn itself. */
export const STOP_RELAY_MS = 3_000;
/** The owning tab says it is alive this often while a turn runs (PO B1); receivers trust a beat for 5 s. */
export const TURN_HEARTBEAT_MS = 2_000;
/** One `{org, sid, origin, turnId, at}` of the user's live-turn ledger (ids only). */
export interface LiveTurn {
org: string;
@@ -61,6 +64,16 @@ export interface LifecycleTrigger {
forced?: boolean;
}
/** A chat another tab of this browser runs or waits on, for the running-chats list. */
export interface RemoteTurn {
sid: Sid;
org: string;
state: "needs_you" | "working";
/** The last `turn_heartbeat` of its owner; `null` when only `turn_started` was heard. */
beatAt: number | null;
expiresAt?: number;
}
// A turn this tab runs, as the other tabs hear about it.
interface LiveMirror {
turnId: string;
@@ -96,6 +109,8 @@ export interface AssistantLifecycleRuntime extends AssistantLifecycle {
otherAccountSignedIn: ComputedRef<boolean>;
/** Same-browser sessions another tab runs or waits on, for the header light. */
remoteLight(): { state: "needs_you" | "working"; sid: Sid } | null;
/** The same sessions of every org, for the running-chats list. */
remoteTurns(): RemoteTurn[];
/** True while another tab holds this session's approval card. */
approvalElsewhere(sid: Sid): boolean;
/** Stop on a turn another tab's card holds: that tab declines the card first, then the turn is cancelled. */
@@ -198,7 +213,16 @@ export function createLifecycle(deps: LifecycleDeps): AssistantLifecycleRuntime
const otherAccount = ref(false);
// Another tab's live sessions for the header light; a tab that closed mid-turn ages out with the ledger's TTL.
const remote = shallowReactive(
new Map<Sid, { org: string; state: "needs_you" | "working"; at: number; expiresAt?: number }>(),
new Map<
Sid,
{
org: string;
state: "needs_you" | "working";
at: number;
beatAt?: number;
expiresAt?: number;
}
>(),
);
const cardLive = (entry: { at: number; expiresAt?: number }, now: number) =>
now - entry.at < APPROVAL_STALE_MS && (entry.expiresAt === undefined || now < entry.expiresAt);
@@ -234,13 +258,26 @@ export function createLifecycle(deps: LifecycleDeps): AssistantLifecycleRuntime
if (!local || !isRunning(local.turn))
remote.set(hint.sid, { org: hint.org, state: "working", at: deps.now() });
}),
hints.on("turn_heartbeat", (hint) => {
const local = store.state.sessions[hint.sid];
if (local && isRunning(local.turn) && local.turnMeta.ownedByThisTab) return;
const at = deps.now();
const known = remote.get(hint.sid);
// Reassigned, so a reader of the beat updates without the map changing shape on every beat.
remote.set(
hint.sid,
known ? { ...known, beatAt: at } : { org: hint.org, state: "working", at, beatAt: at },
);
}),
hints.on("turn_ended", (hint) => void remote.delete(hint.sid)),
hints.on("approval_pending", (hint) => {
if (store.state.sessions[hint.sid]?.pendingApproval) return;
const beatAt = remote.get(hint.sid)?.beatAt;
remote.set(hint.sid, {
org: deps.currentOrg() ?? "",
state: "needs_you",
at: deps.now(),
...(beatAt !== undefined && { beatAt }),
...(hint.expiresAt !== undefined && { expiresAt: hint.expiresAt }),
});
expireLater();
@@ -292,6 +329,23 @@ export function createLifecycle(deps: LifecycleDeps): AssistantLifecycleRuntime
}
return working ? { state: "working", sid: working } : null;
},
remoteTurns() {
const now = deps.now();
const turns: RemoteTurn[] = [];
for (const [sid, entry] of remote) {
const live =
entry.state === "needs_you" ? cardLive(entry, now) : now - entry.at < LIVE_TURN_TTL_MS;
if (!live) continue;
turns.push({
sid,
org: entry.org,
state: entry.state,
beatAt: entry.beatAt ?? null,
...(entry.expiresAt !== undefined && { expiresAt: entry.expiresAt }),
});
}
return turns;
},
approvalElsewhere: (sid) => {
const entry = remote.get(sid);
return entry?.state === "needs_you" && cardLive(entry, deps.now());
@@ -455,7 +509,29 @@ function mirrorTurns(deps: LifecycleDeps): { stop(): void; releaseCards(): void;
origin: known.origin,
at: deps.now(),
});
const announceBeat = (sid: Sid, known: LiveMirror) =>
hints.postFor(known.userOrgKey, {
v: 1,
type: "turn_heartbeat",
org: known.org,
sid,
turnId: known.turnId,
at: deps.now(),
});
const beatNow = () => pendingCards.forEach((card, sid) => announceCard(sid, card));
// Hidden tabs are throttled, so a beat may arrive late; receivers fall back to the server's owner id.
let turnBeat: ReturnType<typeof setInterval> | null = null;
const beatTurns = () => {
if (live.size && !turnBeat)
turnBeat = setInterval(
() => live.forEach((known, sid) => announceBeat(sid, known)),
TURN_HEARTBEAT_MS,
);
else if (!live.size && turnBeat) {
clearInterval(turnBeat);
turnBeat = null;
}
};
// A card whose chat is gone here (a reset, a sign-out) is let go at once, or other tabs would stay locked.
const releaseCard = (sid: Sid) => {
const card = pendingCards.get(sid);
@@ -539,6 +615,7 @@ function mirrorTurns(deps: LifecycleDeps): { stop(): void; releaseCards(): void;
};
live.set(row.sid, mirror);
announceTurn(row.sid, mirror);
announceBeat(row.sid, mirror);
writeLiveTurn(
hints.userHash(),
{ add: { org: row.org, sid: row.sid, origin: row.origin, turnId: row.turnId, at } },
@@ -548,7 +625,13 @@ function mirrorTurns(deps: LifecycleDeps): { stop(): void; releaseCards(): void;
endTurn(deps, row.sid, known, statusOf(row.state), at);
live.delete(row.sid);
}
if (row.card && row.mine && !pendingCards.has(row.sid)) {
const held = pendingCards.get(row.sid);
if (held && row.card === held.callId && row.cardExpiresAt !== held.expiresAt) {
// An extension moved the expiry, or other tabs would drop the card at the old one.
held.expiresAt = row.cardExpiresAt;
announceCard(row.sid, held);
}
if (row.card && row.mine && !held) {
const card: HeldCard = {
userOrgKey: row.userOrgKey,
turnId: row.turnId ?? "",
@@ -574,6 +657,7 @@ function mirrorTurns(deps: LifecycleDeps): { stop(): void; releaseCards(): void;
}
for (const sid of pendingCards.keys()) if (!seen.has(sid)) releaseCard(sid);
beat();
beatTurns();
},
{ deep: true },
);
@@ -586,6 +670,7 @@ function mirrorTurns(deps: LifecycleDeps): { stop(): void; releaseCards(): void;
stop: () => {
stop();
if (heartbeat) clearInterval(heartbeat);
if (turnBeat) clearInterval(turnBeat);
if (typeof window !== "undefined")
listeners.forEach(([type, listener]) => window.removeEventListener(type, listener));
if (typeof document !== "undefined")
+1
View File
@@ -162,6 +162,7 @@ export {
} from "./approvalAnatomy";
export {
approvalClock,
approvalDuration,
approvalEntity,
approvalHeading,
approvalNoun,
+503
View File
@@ -0,0 +1,503 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick, ref } from "vue";
import { fakeAssistant, hintBus } from "@/components/ai-assistant/__tests__/fakeAssistant";
import assistantService, { type ChatSummaryWire } from "@/services/assistant";
import { raw } from "@/types/i18n";
import type { AssistantSession, TurnState } from "./assistantStore.types";
import type { RemoteTurn } from "./lifecycle";
import {
RUNS_CLEARED_PREFIX,
UNSEEN_RUN_MS,
createRuns,
otherOrgRuns,
readRunsCleared,
runAgentKey,
runsCounts,
runsList,
shownSids,
useAssistantRuns,
writeRunsCleared,
type RunsDeps,
type RunsInput,
} from "./runs";
const NOW = 10_000_000;
const KEY = "user:org1";
function session(sid: string, over: Record<string, unknown> = {}): AssistantSession {
return {
sid,
org: "org1",
userOrgKey: KEY,
title: raw(`Chat ${sid}`),
turn: { state: "idle" },
turnMeta: { turnId: "t-" + sid, startedAt: NOW - 5_000, ownedByThisTab: true },
unread: false,
pendingApproval: null,
pendingConsent: null,
record: { ephemeral: false },
...over,
} as unknown as AssistantSession;
}
const answering = (sid: string, over: Record<string, unknown> = {}) =>
session(sid, { turn: { state: "responding.writing" }, ...over });
function chat(sid: string, over: Partial<ChatSummaryWire> = {}): ChatSummaryWire {
return { session_id: sid, title: `Server ${sid}`, updated_at: NOW * 1000, ...over } as never;
}
function input(over: Partial<RunsInput> = {}): RunsInput {
return {
sessions: [],
chats: [],
remote: [],
cleared: {},
org: "org1",
userOrgKey: KEY,
clientId: "me",
now: NOW,
...over,
};
}
describe("runsList", () => {
it("orders needs you, answering, failed, ready, and newest first inside a group", () => {
const rows = runsList(
input({
sessions: [
session("ready", {
unread: true,
turn: { state: "done" },
turnMeta: { endedAt: NOW - 1, startedAt: NOW - 9, ownedByThisTab: true },
}),
session("failed", {
unread: true,
turn: { state: "failed", error: {} } as TurnState,
turnMeta: { endedAt: NOW - 1, ownedByThisTab: true },
}),
answering("old", {
turnMeta: { startedAt: NOW - 100, ownedByThisTab: true, turnId: "a" },
}),
answering("new", {
turnMeta: { startedAt: NOW - 10, ownedByThisTab: true, turnId: "b" },
}),
session("waits", {
pendingApproval: { id: "c1", expiresAt: NOW + 1_000, userExtendsLeft: 4 },
}),
],
}),
);
expect(rows.map((row) => row.sid)).toEqual(["waits", "new", "old", "failed", "ready"]);
expect(rows[0]).toMatchObject({
status: "needsApproval",
request: "c1",
expiresAt: NOW + 1_000,
extendsLeft: 4,
});
});
it("lists one consent request once, however many chats wait on it", () => {
const rows = runsList(
input({
sessions: [
session("a", { pendingConsent: { id: "x" } }),
session("b", { pendingConsent: { id: "x" } }),
session("c", { turn: { state: "awaiting_consent" } }),
],
}),
);
expect(rows.map((row) => row.sid)).toEqual(["a"]);
});
it("keeps out ephemeral chats and another account's sessions", () => {
const rows = runsList(
input({
sessions: [
answering("helper", { record: { ephemeral: true } }),
answering("theirs", { userOrgKey: "user:org2" }),
],
}),
);
expect(rows).toEqual([]);
});
it("claims another tab only on evidence: a fresh beat or another client id (PO B3)", () => {
const follower = { turnMeta: { ownedByThisTab: false, startedAt: NOW - 1, turnId: "t" } };
const noEvidence = runsList(input({ sessions: [answering("s", follower)] }));
expect(noEvidence[0].status).toBe("reconnecting");
const beat: RemoteTurn = { sid: "s", org: "org1", state: "working", beatAt: NOW - 4_999 };
const live = runsList(input({ sessions: [answering("s", follower)], remote: [beat] }));
expect(live[0].status).toBe("elsewhere");
const old: RemoteTurn = { ...beat, beatAt: NOW - 5_000 };
const stale = runsList(input({ sessions: [answering("s", follower)], remote: [old] }));
expect(stale[0].status).toBe("reconnecting");
const owned = runsList(
input({
sessions: [answering("s", follower)],
chats: [chat("s", { active_turn: true, owner_client_id: "other" })],
}),
);
expect(owned[0].status).toBe("elsewhere");
});
it("never claims a turn the server says this very client owns", () => {
const rows = runsList(
input({ chats: [chat("s", { active_turn: true, owner_client_id: "me" })] }),
);
expect(rows[0].status).toBe("reconnecting");
});
it("builds rows from the server for chats this tab never opened", () => {
const rows = runsList(
input({
chats: [
chat("waits", { pending_approval: true }),
chat("runs", { active_turn: true, active_turn_id: "t9", agent_type: "sre" }),
],
}),
);
expect(rows.map((row) => [row.sid, row.status])).toEqual([
["waits", "needsApproval"],
["runs", "reconnecting"],
]);
expect(rows[1]).toMatchObject({ turnId: "t9", agent: "sre" });
});
it("claims a finished chat only when the server says it is unseen", () => {
const finished = { last_turn_status: "completed", last_turn_ended_at: NOW * 1000 };
const unknown = runsList(input({ chats: [chat("s", finished)] }));
expect(unknown).toEqual([]);
const unseen = runsList(input({ chats: [chat("s", { ...finished, seen: false })] }));
expect(unseen[0]).toMatchObject({ status: "ready", group: "ready", stale: false });
const failed = runsList(
input({
chats: [chat("s", { ...finished, seen: false, last_turn_status: "interrupted" })],
}),
);
expect(failed[0]).toMatchObject({ status: "failed", group: "failed" });
const open = runsList(
input({ chats: [chat("s", { ...finished, seen: false })], sessions: [session("s")] }),
);
expect(open).toEqual([]);
});
it("marks a finished row stale after 30 minutes", () => {
const at = NOW - UNSEEN_RUN_MS;
const rows = runsList(
input({
chats: [
chat("s", {
seen: false,
last_turn_status: "completed",
last_turn_ended_at: at * 1000,
}),
],
}),
);
expect(rows[0].stale).toBe(true);
});
it("drops a cleared finished row, and lists it again once the chat finishes later", () => {
const finished = (endedAt: number) =>
session("s", {
unread: true,
turn: { state: "done" },
turnMeta: { endedAt, ownedByThisTab: true },
});
expect(runsList(input({ sessions: [finished(NOW)], cleared: { s: NOW } }))).toEqual([]);
const again = runsList(input({ sessions: [finished(NOW + 1)], cleared: { s: NOW } }));
expect(again.map((row) => row.sid)).toEqual(["s"]);
});
it("hides helper chats the server lists", () => {
const rows = runsList(
input({ chats: [chat("s", { active_turn: true, agent_type: "generate_query" })] }),
);
expect(rows).toEqual([]);
});
});
describe("otherOrgRuns", () => {
it("lists another org's running sessions and remote turns, never this org's", () => {
const rows = otherOrgRuns(
input({
sessions: [
answering("mine"),
answering("kept", { userOrgKey: "user:org2", org: "org2" }),
session("idle", { userOrgKey: "user:org2", org: "org2" }),
],
remote: [
{ sid: "r", org: "org3", state: "needs_you", beatAt: null },
{ sid: "here", org: "org1", state: "working", beatAt: null },
],
}),
);
expect(rows.map((row) => [row.sid, row.org, row.status, row.otherOrg])).toEqual([
["kept", "org2", "working", true],
["r", "org3", "needsApproval", true],
]);
});
});
describe("small helpers", () => {
it("counts per signal", () => {
const rows = runsList(
input({
sessions: [answering("a"), answering("b"), session("c", { pendingApproval: { id: "1" } })],
}),
);
expect(runsCounts(rows)).toEqual({ needs: 1, answering: 2, failed: 0, ready: 0 });
});
it("counts a floating window that is open as visible, and a tray bar or a hidden shell as not", () => {
const shell = (kind: string, windows?: unknown) =>
({ shell: { placement: { kind }, activeSid: "a", windows } }) as never;
expect(shownSids(shell("sidebar"))).toEqual(["a"]);
expect(shownSids(shell("hidden"))).toEqual([]);
expect(
shownSids(
shell("hidden", [
{ sid: "w", state: "open" },
{ sid: "t", state: "tray" },
]),
),
).toEqual(["w"]);
});
it("shows sre-rca as Investigator and never names an unknown agent", () => {
expect(runAgentKey("sre-rca")).toBe("investigator");
expect(runAgentKey("sre")).toBe("investigator");
expect(runAgentKey("generate-query")).toBe("queryBuilder");
expect(runAgentKey("o2-ai")).toBe("general");
expect(runAgentKey(null)).toBe("general");
});
it("merges two tabs' clears and survives blocked storage", () => {
localStorage.clear();
writeRunsCleared(KEY, ["a"], 1);
writeRunsCleared(KEY, ["b"], 2);
expect(readRunsCleared(KEY)).toEqual({ a: 1, b: 2 });
localStorage.setItem(`${RUNS_CLEARED_PREFIX}${KEY}`, "not json");
expect(readRunsCleared(KEY)).toEqual({});
expect(readRunsCleared(null)).toEqual({});
});
it("gives a usable empty list before the runtime exists", async () => {
const runs = useAssistantRuns();
expect(runs.rows.value).toEqual([]);
expect(runs.cap.value).toBe(3);
expect(runs.light()).toBeNull();
await expect(runs.requestOpen()).resolves.toBeUndefined();
});
});
describe("createRuns", () => {
let dispose: () => void = () => undefined;
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
dispose();
vi.useRealTimers();
vi.restoreAllMocks();
});
function setup(over: Partial<RunsDeps> = {}) {
const fake = fakeAssistant();
const hints = hintBus();
const store = fake.store;
let feed: (chats: ChatSummaryWire[]) => void = () => undefined;
const remote = ref<RemoteTurn[]>([]);
const deps = {
store: () => store,
hints,
remote: () => remote.value,
now: () => Date.now(),
org: () => "default",
clientId: () => "me",
serverMode: () => true,
watchActive: vi.fn((_org: string, onChats: (chats: ChatSummaryWire[]) => void) => {
feed = onChats;
return () => undefined;
}),
invalidateActive: vi.fn(),
reveal: vi.fn(async () => undefined),
focusCard: vi.fn(() => true),
stopElsewhere: vi.fn(async () => undefined),
approvalElsewhere: vi.fn(() => false),
...over,
};
const runs = createRuns(deps as RunsDeps);
dispose = () => runs.dispose();
const sid = store.createSession({
origin: { entryKind: "header_button", objectKind: "none", container: "sidebar" },
thread: "new",
});
return {
store,
hints,
deps,
runs,
sid,
remote,
feed: (chats: ChatSummaryWire[]) => feed(chats),
};
}
it("counts a chat waiting on an approval as running, and each decision once", async () => {
const { store, runs, sid, feed } = setup();
store.state.sessions[sid].pendingApproval = { id: "c1" } as never;
feed([chat("other", { active_turn: true }), chat("far", { pending_approval: true })]);
await nextTick();
expect(runs.active.value).toBe(3);
expect(runs.needsCount.value).toBe(2);
});
it("leaves out of the count a chat waiting on paid usage, which was never admitted", async () => {
const { store, runs, sid } = setup();
store.state.sessions[sid].pendingConsent = { id: "x" } as never;
await nextTick();
expect(runs.active.value).toBe(0);
expect(runs.needsCount.value).toBe(1);
});
it("lists as a decision only what no visible container shows", async () => {
const { store, runs, sid } = setup();
store.state.sessions[sid].pendingApproval = { id: "c1" } as never;
await nextTick();
expect(runs.decisions.value.map((row) => row.sid)).toEqual([sid]);
store.state.shell.activeSid = sid;
store.state.shell.placement = { kind: "sidebar" } as never;
await nextTick();
expect(runs.decisions.value).toEqual([]);
});
it("stops following the active read when the org changes, and never in browser mode", async () => {
const org = ref("default");
const stop = vi.fn();
const watchActive = vi.fn(() => stop);
setup({ org: () => org.value, watchActive });
expect(watchActive).toHaveBeenCalledTimes(1);
org.value = "other";
await nextTick();
expect(stop).toHaveBeenCalledTimes(1);
expect(watchActive).toHaveBeenCalledTimes(2);
dispose();
const browserOnly = vi.fn(() => stop);
setup({ serverMode: () => false, watchActive: browserOnly });
expect(browserOnly).not.toHaveBeenCalled();
});
it("clears across tabs: the row leaves, and the hint carries only the id", async () => {
const { hints, runs, feed, store } = setup();
feed([chat("s", { seen: false, last_turn_status: "completed" })]);
await nextTick();
expect(runs.rows.value).toHaveLength(1);
runs.clear("s");
await nextTick();
expect(runs.rows.value).toEqual([]);
expect(hints.post).toHaveBeenCalledWith({ v: 1, type: "runs_cleared", sid: "s", at: NOW });
expect(readRunsCleared(store.state.userOrgKey)).toEqual({ s: NOW });
});
it("re-reads the cleared set when another tab clears", async () => {
const { hints, runs, feed, store } = setup();
feed([chat("s", { seen: false, last_turn_status: "completed" })]);
await nextTick();
writeRunsCleared(store.state.userOrgKey, ["s"], NOW);
hints.fire({ type: "runs_cleared", sid: "s" });
await nextTick();
expect(runs.rows.value).toEqual([]);
});
it("stops by the route that fits who runs the turn", async () => {
const cancel = vi.spyOn(assistantService, "cancelTurn").mockResolvedValue(undefined as never);
const { store, runs, deps, feed, sid } = setup();
const own = vi.spyOn(store, "stop").mockResolvedValue(undefined as never);
store.state.sessions[sid].turn = { state: "responding.writing" };
store.state.sessions[sid].turnMeta.ownedByThisTab = true;
await nextTick();
await runs.stop(runs.rows.value[0]);
expect(own).toHaveBeenCalledWith(sid);
feed([chat("far", { active_turn: true, active_turn_id: "t9" })]);
await nextTick();
const far = runs.rows.value.find((row) => row.sid === "far")!;
await runs.stop(far);
expect(cancel).toHaveBeenCalledWith("default", "far", "t9");
expect(deps.invalidateActive).toHaveBeenCalledWith("default");
});
it("asks another tab to decline its card before the turn is cancelled", async () => {
const stopElsewhere = vi.fn(async () => undefined);
const { runs, feed } = setup({ stopElsewhere, approvalElsewhere: () => true });
feed([chat("far", { active_turn: true, active_turn_id: "t9" })]);
await nextTick();
await runs.stop(runs.rows.value[0]);
expect(stopElsewhere).toHaveBeenCalledWith("far");
});
it("opens a row, reveals the container, and lands on the card for Review", async () => {
const { store, runs, deps, sid } = setup();
const open = vi.spyOn(store, "openSession").mockResolvedValue(sid);
store.state.sessions[sid].pendingApproval = { id: "c1" } as never;
await nextTick();
const opening = runs.open(runs.rows.value[0], { focus: "card" });
await vi.advanceTimersByTimeAsync(10);
await opening;
expect(open).toHaveBeenCalledWith({ sid }, { focus: false });
expect(deps.reveal).toHaveBeenCalledTimes(1);
expect(deps.focusCard).toHaveBeenCalled();
});
it("holds a request for the list until a Runs button takes it", async () => {
const { runs, deps } = setup();
await runs.requestOpen();
expect(deps.reveal).toHaveBeenCalledTimes(1);
expect(runs.openRequested.value).toBe(true);
runs.takeOpenRequest();
expect(runs.openRequested.value).toBe(false);
});
it("takes the real cap from a refused send", async () => {
const { store, runs, sid } = setup();
expect(runs.cap.value).toBe(3);
store.state.shell.activeSid = sid;
store.state.sessions[sid].turn = {
state: "refused",
error: { code: "run_limit_member", limit: 7 },
} as never;
await nextTick();
expect(runs.cap.value).toBe(7);
});
it("lights the header from chats no tab of this browser runs", async () => {
const { runs, feed } = setup();
feed([chat("far", { active_turn: true })]);
await nextTick();
expect(runs.light()).toEqual({ state: "working", sid: "far" });
feed([chat("far", { active_turn: true }), chat("wait", { pending_approval: true })]);
expect(runs.light()).toEqual({ state: "needs_you", sid: "wait" });
});
});
+608
View File
@@ -0,0 +1,608 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, nextTick, ref, shallowRef, watch, type ComputedRef, type Ref } from "vue";
import assistantService, { type ChatSummaryWire } from "@/services/assistant";
import { raw, type I18nText } from "@/types/i18n";
import { isRunning, type AssistantStoreInternal } from "./assistantStore";
import type { AssistantSession, AssistantState, HintChannel, Sid } from "./assistantStore.types";
import { isHelperChat } from "./historyContext";
import { HEARTBEAT_TRUST_MS } from "./historyElsewhere";
import type { RemoteTurn } from "./lifecycle";
import { runtimeHandle } from "./runtimeHandle";
/** The list shows the server's cap once a 429 named it; until then this default (`80` §1.3). */
export const RUN_CAP_DEFAULT = 3;
/** An unseen finished row older than this stays only in the rail and history (D2 §5.5). */
export const UNSEEN_RUN_MS = 30 * 60_000;
export const RUNS_CLEARED_PREFIX = "o2.assistant.runsCleared:";
// Old entries go first; a chat that finished again is listed regardless of its old entry.
const MAX_CLEARED = 200;
// Elapsed times and the heartbeat's 5 s window move on this tick while any row is listed.
const RUNS_TICK_MS = 1_000;
const GROUP_ORDER: Record<RunGroup, number> = { needs_you: 0, answering: 1, failed: 2, ready: 3 };
const FINISHED: Record<string, RunStatus> = {
completed: "ready",
failed: "failed",
interrupted: "failed",
cancelled: "stopped",
};
export type RunGroup = "needs_you" | "answering" | "failed" | "ready";
export type RunStatus =
"working" | "reconnecting" | "elsewhere" | "needsApproval" | "ready" | "failed" | "stopped";
/** One row of Chats in progress. */
export interface RunRow {
sid: Sid;
org: string;
/** `null`: no title is known here, so the row says "New chat" or "A chat in {org}". */
title: I18nText | null;
group: RunGroup;
status: RunStatus;
/** A wire agent id; `null` reads as the default agent. */
agent: string | null;
/** When it started or finished, for "newest first" and the row's time. */
at: number;
startedAt: number | null;
turnId: string | null;
/** Identifies the decision a needs-you row waits on; one consent request is one decision for every chat. */
request: string | null;
expiresAt: number | null;
/** The card's user More time left, once the server said. */
extendsLeft: number | null;
consent: boolean;
/** Another org's chat: counted in the light, never opened in place (PO B51). */
otherOrg: boolean;
/** Past the 30 minutes: the rail keeps it, the list does not. */
stale: boolean;
}
export interface RunsInput {
sessions: AssistantSession[];
chats: ChatSummaryWire[];
remote: RemoteTurn[];
cleared: Readonly<Record<Sid, number>>;
org: string;
userOrgKey: string | null;
clientId: string;
now: number;
}
export interface RunsCounts {
needs: number;
answering: number;
failed: number;
ready: number;
}
export interface RunsDeps {
store(): AssistantStoreInternal;
hints: Pick<HintChannel, "on" | "post">;
remote(): RemoteTurn[];
now(): number;
org(): string | null;
clientId(): string;
serverMode(): boolean;
/** Follows the `state=active` read; returns its stop. */
watchActive(org: string, onChats: (chats: ChatSummaryWire[]) => void): () => void;
invalidateActive(org: string): void;
/** Shows the container on the active chat, wherever it lives. */
reveal(invoker: HTMLElement | null): Promise<unknown>;
focusCard(): boolean;
stopElsewhere(sid: Sid): Promise<void>;
approvalElsewhere(sid: Sid): boolean;
}
export interface AssistantRuns {
/** Everything the list shows, this org first and then other orgs' rows. */
rows: ComputedRef<RunRow[]>;
/** `rows` plus the finished rows past 30 minutes, for the full-view rail. */
rail: ComputedRef<RunRow[]>;
otherOrg: ComputedRef<RunRow[]>;
cap: ComputedRef<number>;
/** Running chats, a chat waiting on an approval included (D2 decision 10). */
active: ComputedRef<number>;
/** One per decision, however many chats wait on it; other orgs' count too, as in the light. */
needsCount: ComputedRef<number>;
/** Decisions no visible container shows, for the notice and the expiry warnings. */
decisions: ComputedRef<RunRow[]>;
/** Set until a Runs button takes the request, so a container that mounts late still opens the list. */
openRequested: Readonly<Ref<boolean>>;
/** Shows the container and asks its Runs button for the list. */
requestOpen(invoker?: HTMLElement | null): Promise<void>;
takeOpenRequest(): void;
clear(sid: Sid): void;
clearFinished(): void;
stop(row: RunRow): Promise<void>;
open(
row: RunRow,
opts?: { focus?: "composer" | "card"; invoker?: HTMLElement | null },
): Promise<void>;
/** The chats no tab of this browser runs: the header light's last source. */
light(): { state: "needs_you" | "working"; sid: Sid } | null;
dispose(): void;
}
/** The rows of Chats in progress for one user+org, in the list's order (D2 §5.5). */
export function runsList(input: RunsInput): RunRow[] {
const rows = new Map<Sid, RunRow>();
let consentListed = false;
for (const session of input.sessions) {
const row = sessionRow(session, input);
if (!row || (row.consent && consentListed)) continue;
consentListed ||= row.consent;
rows.set(session.sid, row);
}
for (const turn of input.remote) {
const known = rows.get(turn.sid);
if (known?.status === "working" && !turn.beatAt) continue;
if (known?.group === "needs_you" && turn.state === "working") continue;
const row = turn.org && turn.org !== input.org ? null : remoteRow(turn, input);
if (row) rows.set(turn.sid, row);
}
const sessions = new Map(input.sessions.map((session) => [session.sid, session]));
for (const chat of input.chats) {
if (rows.has(chat.session_id)) continue;
const row = serverRow(chat, sessions.get(chat.session_id), input);
if (row) rows.set(chat.session_id, row);
}
return [...rows.values()]
.filter((row) => !isCleared(row, input.cleared))
.sort((a, b) => GROUP_ORDER[a.group] - GROUP_ORDER[b.group] || b.at - a.at);
}
/** Another org's chats this tab knows of, after every current-org row; they never count toward `active`. */
export function otherOrgRuns(
input: Pick<RunsInput, "sessions" | "remote" | "org" | "userOrgKey" | "now">,
): RunRow[] {
const rows = new Map<Sid, RunRow>();
for (const session of input.sessions) {
if (session.userOrgKey === input.userOrgKey || session.record.ephemeral) continue;
if (!isRunning(session.turn) && !session.pendingApproval) continue;
rows.set(session.sid, {
...baseRow(session.sid, session.org, input.now),
title: session.title,
group: session.pendingApproval ? "needs_you" : "answering",
status: session.pendingApproval ? "needsApproval" : "working",
request: session.pendingApproval?.id ?? null,
otherOrg: true,
});
}
for (const turn of input.remote) {
if (!turn.org || turn.org === input.org || rows.has(turn.sid)) continue;
rows.set(turn.sid, {
...baseRow(turn.sid, turn.org, input.now),
group: turn.state === "needs_you" ? "needs_you" : "answering",
status: turn.state === "needs_you" ? "needsApproval" : "working",
request: turn.state === "needs_you" ? turn.sid : null,
otherOrg: true,
});
}
return [...rows.values()];
}
/** Counts per signal, for the Runs button and the list's summary line. */
export function runsCounts(rows: readonly RunRow[]): RunsCounts {
const counts: RunsCounts = { needs: 0, answering: 0, failed: 0, ready: 0 };
for (const row of rows) {
if (row.group === "needs_you") counts.needs += 1;
else counts[row.group] += 1;
}
return counts;
}
/** The chats a visible container shows; a tray bar is not visible. */
export function shownSids(state: Pick<AssistantState, "shell">): Sid[] {
const { shell } = state;
const active = shell.placement.kind !== "hidden" && shell.activeSid ? [shell.activeSid] : [];
// The floating windows land with their own lane; an open one counts as visible here.
const windows = (shell as { windows?: { sid: Sid; state: string }[] }).windows ?? [];
return [...active, ...windows.filter((w) => w.state === "open").map((w) => w.sid)];
}
/** The display name of a wire agent id; `sre-rca` runs show as Investigator (D2 §4). */
export function runAgentKey(agent: string | null): "general" | "investigator" | "queryBuilder" {
if (agent === "sre" || agent === "sre-rca") return "investigator";
return agent === "generate-query" || agent === "generate_query" ? "queryBuilder" : "general";
}
/** `{ sid: when }` of the rows cleared in this browser; blocked storage clears nothing across tabs. */
export function readRunsCleared(userOrgKey: string | null): Record<Sid, number> {
if (!userOrgKey) return {};
try {
const parsed: unknown = JSON.parse(
localStorage.getItem(`${RUNS_CLEARED_PREFIX}${userOrgKey}`) ?? "{}",
);
return parsed && typeof parsed === "object" ? (parsed as Record<Sid, number>) : {};
} catch {
return {};
}
}
/** Read-merge-write, so two tabs clearing at once both keep theirs. */
export function writeRunsCleared(
userOrgKey: string | null,
sids: readonly Sid[],
at: number,
): Record<Sid, number> {
const merged = { ...readRunsCleared(userOrgKey) };
for (const sid of sids) merged[sid] = at;
const kept = Object.entries(merged)
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_CLEARED);
const next = Object.fromEntries(kept);
try {
if (userOrgKey)
localStorage.setItem(`${RUNS_CLEARED_PREFIX}${userOrgKey}`, JSON.stringify(next));
} catch {
// Blocked storage: the row stays cleared in this tab until it reloads.
}
return next;
}
/** Builds the runtime's one list; the store and the hints exist by the time anything reads it. */
export function createRuns(deps: RunsDeps): AssistantRuns {
const state = () => deps.store().state;
const chats = shallowRef<ChatSummaryWire[]>([]);
const cleared = shallowRef<Record<Sid, number>>({});
const clock = ref(deps.now());
const limit = ref<number | null>(null);
const openRequested = ref(false);
const key = computed(() => state().userOrgKey);
const input = (): RunsInput => ({
sessions: Object.values(state().sessions),
chats: chats.value,
remote: deps.remote(),
cleared: cleared.value,
org: deps.org() ?? "",
userOrgKey: key.value,
clientId: deps.clientId(),
now: clock.value,
});
const all = computed(() => runsList(input()));
const otherOrg = computed(() => otherOrgRuns(input()));
const listed = computed(() => all.value.filter((row) => !row.stale));
const rows = computed(() => [...listed.value, ...otherOrg.value]);
const rail = computed(() => [...all.value, ...otherOrg.value]);
const running = computed(() =>
listed.value.filter(
(row) => (row.group === "needs_you" && !row.consent) || row.group === "answering",
),
);
const decisions = computed(() => {
const shown = shownSids(state());
return listed.value.filter((row) => row.group === "needs_you" && !shown.includes(row.sid));
});
const finished = (row: RunRow) => row.group === "failed" || row.group === "ready";
const clearAll = (sids: Sid[]) => {
if (!sids.length) return;
const store = deps.store();
cleared.value = writeRunsCleared(key.value, sids, deps.now());
for (const sid of sids) {
store.markRead(sid);
deps.hints.post({ v: 1, type: "runs_cleared", sid, at: deps.now() });
}
};
// Seen means opened in any container (D2 §5.5), so a finished row the user is looking at leaves by itself.
const stopSeen = watch(
() =>
listed.value
.filter((row) => finished(row) && shownSids(state()).includes(row.sid))
.map((row) => row.sid),
clearAll,
);
// A 429 names the real cap, which an admin may have changed from the default.
const stopLimit = watch(
() => {
const turn = state().shell.activeSid
? state().sessions[state().shell.activeSid as Sid]?.turn
: undefined;
return turn?.state === "refused" && turn.error.code === "run_limit_member"
? (turn.error.limit ?? null)
: null;
},
(value) => {
if (value !== null) limit.value = value;
},
);
const stopCleared = deps.hints.on("runs_cleared", () => {
cleared.value = readRunsCleared(key.value);
});
const stopKey = watch(
key,
(value) => {
cleared.value = readRunsCleared(value);
chats.value = [];
},
{ immediate: true },
);
let stopActive: (() => void) | null = null;
const stopFollow = watch(
() => (deps.serverMode() ? deps.org() : null),
(org) => {
stopActive?.();
stopActive = null;
if (org) stopActive = deps.watchActive(org, (next) => (chats.value = next));
},
{ immediate: true },
);
let timer: ReturnType<typeof setInterval> | null = null;
const stopTick = watch(
() => all.value.length + otherOrg.value.length > 0,
(any) => {
if (timer) clearInterval(timer);
timer = any ? setInterval(() => (clock.value = deps.now()), RUNS_TICK_MS) : null;
},
{ immediate: true },
);
const stopRow = async (row: RunRow) => {
const store = deps.store();
const session = store.state.sessions[row.sid];
if (session?.turnMeta.ownedByThisTab) await store.stop(row.sid);
else if (deps.approvalElsewhere(row.sid)) await deps.stopElsewhere(row.sid);
else if (session) await store.cancelTurn(row.sid, row.turnId);
// A chat only the server lists has no session here; the cancel still reaches its turn (MA-10 AC 3).
else if (row.turnId) await assistantService.cancelTurn(row.org, row.sid, row.turnId);
deps.invalidateActive(row.org);
};
const openRow: AssistantRuns["open"] = async (row, opts = {}) => {
const store = deps.store();
const focus = opts.focus ?? "composer";
await store
.openSession({ sid: row.sid }, { focus: focus === "composer" })
.catch(() => undefined);
await deps.reveal(opts.invoker ?? null);
if (finished(row)) clearAll([row.sid]);
if (focus !== "card") return;
// The card mounts with the chat, which can take a render after the container shows.
await nextTick();
if (!deps.focusCard()) await nextTick(() => deps.focusCard());
};
return {
rows,
rail,
otherOrg,
cap: computed(() => limit.value ?? RUN_CAP_DEFAULT),
active: computed(() => running.value.length),
needsCount: computed(
() =>
new Set(rows.value.filter((row) => row.group === "needs_you").map((row) => row.request))
.size,
),
decisions,
openRequested,
async requestOpen(invoker = null) {
await deps.reveal(invoker);
openRequested.value = true;
},
takeOpenRequest: () => void (openRequested.value = false),
clear: (sid) => clearAll([sid]),
clearFinished: () => clearAll(listed.value.filter(finished).map((row) => row.sid)),
stop: stopRow,
open: openRow,
light() {
const sessions = state().sessions;
const others = chats.value.filter(
(chat) => !sessions[chat.session_id]?.turnMeta.ownedByThisTab,
);
const waiting = others.find((chat) => chat.pending_approval);
if (waiting) return { state: "needs_you", sid: waiting.session_id };
const working = others.find((chat) => chat.active_turn);
return working ? { state: "working", sid: working.session_id } : null;
},
dispose() {
if (timer) clearInterval(timer);
stopActive?.();
for (const stop of [stopSeen, stopLimit, stopKey, stopFollow, stopTick, stopCleared]) stop();
},
};
}
/** The runtime's list, or an empty one before the runtime exists. */
export function useAssistantRuns(): AssistantRuns {
return runtimeHandle().value?.runs ?? emptyRuns();
}
function emptyRuns(): AssistantRuns {
const none = computed<RunRow[]>(() => []);
const zero = computed(() => 0);
return {
rows: none,
rail: none,
otherOrg: none,
cap: computed(() => RUN_CAP_DEFAULT),
active: zero,
needsCount: zero,
decisions: none,
openRequested: ref(false),
requestOpen: async () => undefined,
takeOpenRequest: () => undefined,
clear: () => undefined,
clearFinished: () => undefined,
stop: async () => undefined,
open: async () => undefined,
light: () => null,
dispose: () => undefined,
};
}
function baseRow(sid: Sid, org: string, now: number): RunRow {
return {
sid,
org,
title: null,
group: "answering",
status: "working",
agent: null,
at: now,
startedAt: null,
turnId: null,
request: null,
expiresAt: null,
extendsLeft: null,
consent: false,
otherOrg: false,
stale: false,
};
}
function isCleared(row: RunRow, cleared: Readonly<Record<Sid, number>>): boolean {
if (row.group !== "failed" && row.group !== "ready") return false;
return cleared[row.sid] !== undefined && cleared[row.sid] >= row.at;
}
function hasEvidence(
session: AssistantSession,
chat: ChatSummaryWire | undefined,
input: RunsInput,
): boolean {
const beatAt = input.remote.find((turn) => turn.sid === session.sid)?.beatAt;
if (beatAt && input.now - beatAt < HEARTBEAT_TRUST_MS) return true;
return !!chat?.owner_client_id && chat.owner_client_id !== input.clientId;
}
function sessionRow(session: AssistantSession, input: RunsInput): RunRow | null {
if (session.userOrgKey !== input.userOrgKey || session.record.ephemeral) return null;
const meta = session.turnMeta;
const started = meta.startedAt ?? null;
const base: RunRow = {
...baseRow(session.sid, session.org, input.now),
title: session.title,
agent: agentOf(session, input),
at: started ?? input.now,
startedAt: started,
turnId: meta.turnId,
};
if (session.pendingConsent)
return {
...base,
group: "needs_you",
status: "needsApproval",
request: "consent",
consent: true,
};
// A chat waiting on someone else's paid-usage card was never admitted, so that card's chat speaks for it.
if (session.turn.state === "awaiting_consent") return null;
const pending = session.pendingApproval;
if (pending)
return {
...base,
group: "needs_you",
status: "needsApproval",
request: pending.id,
expiresAt: pending.expiresAt ?? null,
extendsLeft: pending.userExtendsLeft ?? null,
};
if (isRunning(session.turn)) return { ...base, status: runningStatus(session, input) };
return session.unread ? finishedRow(session, base, input) : null;
}
function runningStatus(session: AssistantSession, input: RunsInput): RunStatus {
if (session.turn.state === "reconnecting") return "reconnecting";
if (session.turnMeta.ownedByThisTab) return "working";
const chat = input.chats.find((candidate) => candidate.session_id === session.sid);
return hasEvidence(session, chat, input) ? "elsewhere" : "reconnecting";
}
function finishedRow(session: AssistantSession, base: RunRow, input: RunsInput): RunRow {
const at = session.turnMeta.endedAt ?? base.at;
const stale = input.now - at >= UNSEEN_RUN_MS;
const { state } = session.turn;
if (state === "stopped") return { ...base, group: "ready", status: "stopped", at, stale };
if (state === "failed" || state === "refused")
return { ...base, group: "failed", status: "failed", at, stale };
return { ...base, group: "ready", status: "ready", at, stale };
}
function remoteRow(turn: RemoteTurn, input: RunsInput): RunRow | null {
const session = input.sessions.find((candidate) => candidate.sid === turn.sid);
if (session?.userOrgKey !== undefined && session.userOrgKey !== input.userOrgKey) return null;
if (session?.record.ephemeral) return null;
const chat = input.chats.find((candidate) => candidate.session_id === turn.sid);
const fresh = !!turn.beatAt && input.now - turn.beatAt < HEARTBEAT_TRUST_MS;
const owned = !!chat?.owner_client_id && chat.owner_client_id !== input.clientId;
const needs = turn.state === "needs_you";
return {
...baseRow(turn.sid, input.org, input.now),
title: session?.title ?? (chat ? raw(chat.title) : null),
agent: session ? agentOf(session, input) : (chat?.agent_type ?? null),
at: session?.turnMeta.startedAt ?? input.now,
startedAt: session?.turnMeta.startedAt ?? null,
turnId: session?.turnMeta.turnId ?? chat?.active_turn_id ?? null,
group: needs ? "needs_you" : "answering",
status: needs ? "needsApproval" : fresh || owned ? "elsewhere" : "reconnecting",
request: needs ? turn.sid : null,
expiresAt: turn.expiresAt ?? null,
};
}
// Without an owner id the server's own word that a turn runs is all there is, so the row states that and no more (PO B3).
function serverRow(
chat: ChatSummaryWire,
session: AssistantSession | undefined,
input: RunsInput,
): RunRow | null {
if (isHelperChat({ agentType: chat.agent_type, title: chat.title })) return null;
const startedAt = chat.updated_at ? Math.floor(chat.updated_at / 1000) : null;
const base: RunRow = {
...baseRow(chat.session_id, input.org, input.now),
title: chat.title ? raw(chat.title) : null,
agent: chat.agent_type ?? null,
at: startedAt ?? input.now,
startedAt,
turnId: chat.active_turn_id ?? null,
};
if (chat.pending_approval)
return { ...base, group: "needs_you", status: "needsApproval", request: chat.session_id };
if (chat.active_turn) {
const other = !!chat.owner_client_id && chat.owner_client_id !== input.clientId;
return { ...base, status: other ? "elsewhere" : "reconnecting" };
}
// A chat this tab has open was seen here; `seen` absent means a server that cannot say, so nothing is claimed.
if (session || chat.seen !== false) return null;
const status = FINISHED[chat.last_turn_status ?? ""];
if (!status) return null;
const at = chat.last_turn_ended_at ? Math.floor(chat.last_turn_ended_at / 1000) : base.at;
const stale = input.now - at >= UNSEEN_RUN_MS;
const group: RunGroup = status === "failed" ? "failed" : "ready";
return { ...base, group, status, at, stale };
}
// `agentId` lands with the picker; until then the server's list says who answers.
function agentOf(session: AssistantSession, input: RunsInput): string | null {
const chosen = (session as { agentId?: string }).agentId;
return chosen ?? input.chats.find((chat) => chat.session_id === session.sid)?.agent_type ?? null;
}
@@ -29,6 +29,7 @@ import * as storeModule from "./assistantStore";
import { assistantStore, openAssistant, useAssistantCapability } from "./index";
import { openTabWithoutOpener } from "./openTab";
import { pushRecall, readRecall } from "./prefs";
import { useAssistantRuns } from "./runs";
import { getAssistantRuntime, resetAssistantRuntimeForTests } from "./runtime";
vi.mock("@/aws-exports", () => ({ default: { isEnterprise: "true", isCloud: "false" } }));
@@ -81,6 +82,12 @@ describe("assistant runtime", () => {
expect(createStore).toHaveBeenCalledTimes(1);
});
it("builds one running-chats list and hands it to every reader", () => {
const runtime = getAssistantRuntime();
expect(useAssistantRuns()).toBe(runtime.runs);
expect(runtime.runs.rows.value).toEqual([]);
});
it("resolves the gate from the wired getters and lets a door open", async () => {
assistantStore.init(context("admin"));
await flushPromises();
+125 -2
View File
@@ -17,9 +17,11 @@ import {
computed,
effectScope,
markRaw,
ref,
shallowReactive,
shallowRef,
watch,
type Ref,
type ShallowRef,
} from "vue";
import {
@@ -40,6 +42,8 @@ import { registerSessionConsentSurface } from "@/composables/usePaidOverageConse
import assistantService, {
type AssistantCapabilitiesResponse,
type AssistantUsage,
type ChatSummaryWire,
type ChatsPage,
} from "@/services/assistant";
import type { PaidOverageFeature } from "@/services/paidOverage";
import { paidOverageQuery, setPaidOverageMutation } from "@/services/paidOverage.queries";
@@ -47,7 +51,11 @@ import searchService from "@/services/search";
import { toast } from "@/lib/feedback/Toast/useToast";
import { gt } from "@/types/i18n";
import { copyToClipboard } from "@/utils/clipboard";
import { assistantUsageQuery, capabilitiesQuery } from "@/services/assistant.queries";
import {
assistantActiveChatsQuery,
assistantUsageQuery,
capabilitiesQuery,
} from "@/services/assistant.queries";
import { assistantKeys } from "@/services/assistant.querykeys";
import { aiChatShareKeys } from "@/services/ai_chat_share.querykeys";
import type { TelemetryContextGetters, TelemetryRole } from "@/services/assistantAnalytics";
@@ -58,6 +66,7 @@ import { requestDashboardDraft } from "./actions/appliers/drafts";
import { pruneQueryRuns } from "./actions/appliers/query";
import { registerBuiltInAppliers } from "./actions/appliers";
import { createActionDispatcher, type ActionEnv } from "./actions/registry";
import { createApprovalKeepAlive, type ApprovalKeepAlive } from "./approvalKeepAlive";
import {
containerOf,
createAssistantStore,
@@ -74,12 +83,17 @@ import type {
import { cancelConsentOnClose, createConsentRuntime, type ConsentRuntime } from "./consent";
import { serialize } from "./context/serialize";
import { createHintChannel, type AssistantHintChannel } from "./hints";
import { isAssistantV2 } from "./flags";
import { createIndexedDbHistory } from "./historyDb";
import { assistantClientId } from "./historyElsewhere";
import { cardEnding } from "./historyRecords";
import { createLifecycle, type AssistantLifecycleRuntime } from "./lifecycle";
import { forgetHistoryKeys, historyKeysOf, markKeptOnSignOut, readClearOnSignOut } from "./prefs";
import { createOpenAssistant, type OpenAssistantCall } from "./openAssistant";
import { openTabWithoutOpener } from "./openTab";
import { createRuns, shownSids, type AssistantRuns, type RunsDeps } from "./runs";
import { setRuntimeHandle } from "./runtimeHandle";
import { assistantFocusCard } from "./shortcuts";
import { createTurnEngine, type TurnEngineDeps } from "./turnEngine";
import {
createAssistantCapability,
@@ -127,6 +141,8 @@ export interface AssistantRuntime {
approvals: ApprovalsRuntime;
consent: ConsentRuntime;
history: AssistantHistory;
runs: AssistantRuns;
keepAlive: ApprovalKeepAlive;
context: ShallowRef<AssistantRuntimeContext | null>;
/** Set by `telemetryContext`; the `context.client.ui_version` of every turn (B-13). */
uiVersion: ShallowRef<string>;
@@ -152,6 +168,8 @@ export function getAssistantRuntime(): AssistantRuntime {
export function resetAssistantRuntimeForTests(): void {
runtime?.capability.dispose();
runtime?.history.dispose();
runtime?.keepAlive.dispose();
runtime?.runs.dispose();
runtime?.lifecycle.dispose();
runtime?.hints.close();
runtime = null;
@@ -183,6 +201,7 @@ function buildRuntime(): AssistantRuntime {
const context = shallowRef<AssistantRuntimeContext | null>(null);
const uiVersion = shallowRef("");
const { lgUp } = useBreakpoint();
const pageVisible = usePageVisible();
const deps: AssistantStoreDeps = inertAssistantDeps({
getOrg: () => context.value?.getOrg() ?? null,
getUser: () => context.value?.getUser() ?? null,
@@ -218,8 +237,10 @@ function buildRuntime(): AssistantRuntime {
resend: (sid, snapshot, input, opts) => store.resend(sid, snapshot, input, opts),
engine: () => deps.engine,
dispatch: dispatcher.approvals,
extend: (org, sid, id) => useAssistantClient().confirm(org, sid, { id, extend: true }),
extend: (org, sid, id, by) =>
useAssistantClient().confirm(org, sid, { id, extend: true, by }),
extendable: () => capability.capability.value.approvalExtend === true,
readCard,
changed: refreshLists,
hints,
now: deps.now,
@@ -235,6 +256,7 @@ function buildRuntime(): AssistantRuntime {
signOutKeys: () => orgKeysOf(context.value),
remoteLight: () =>
lifecycle.remoteLight() ??
runs.light() ??
(deps.history as Partial<AssistantHistory>).listLight?.value ??
null,
turnLock: (sid) => lifecycle.turnLock(sid),
@@ -291,6 +313,25 @@ function buildRuntime(): AssistantRuntime {
refreshUsage(hint.org);
});
hints.on("consent_granted", (hint) => refreshUsage(hint.org));
const runs = createRuns(
runsDeps(
context,
() => store,
() => capability,
() => lifecycle,
hints,
),
);
const keepAlive = createApprovalKeepAlive({
state: () => store.state,
shownHere: () => (pageVisible.value ? shownSids(store.state) : []),
budgets: () => capability.capability.value.approvalExtendBudgets === true,
locks: locksApi(),
extend: (sid, id) => approvals.extendInBackground(sid, id),
reconcile: (sid) => approvals.reconcile(sid),
now: deps.now,
onReturn,
});
const openAssistant = createOpenAssistant({
store,
capability: () => capability.capability.value,
@@ -315,6 +356,8 @@ function buildRuntime(): AssistantRuntime {
approvals,
consent,
history,
runs,
keepAlive,
context,
uiVersion,
openAssistant,
@@ -322,6 +365,86 @@ function buildRuntime(): AssistantRuntime {
})!;
}
// `document.visibilityState` is not reactive, and the shown lock must drop the moment the page hides.
function usePageVisible(): Ref<boolean> {
const visible = ref(typeof document === "undefined" || document.visibilityState !== "hidden");
if (typeof document !== "undefined")
document.addEventListener("visibilitychange", () => {
visible.value = document.visibilityState !== "hidden";
});
return visible;
}
function locksApi(): Pick<LockManager, "request" | "query"> | null {
return typeof navigator !== "undefined" && navigator.locks ? navigator.locks : null;
}
function onReturn(cb: () => void): () => void {
if (typeof window === "undefined") return () => undefined;
const onVisible = () => {
if (document.visibilityState === "visible") cb();
};
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", cb);
window.addEventListener("online", cb);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", cb);
window.removeEventListener("online", cb);
};
}
async function readCard(org: string, sid: string, turnId: string, id: string) {
const detail = await assistantService.getChat(org, sid);
const row = detail.approvals?.find((approval) => approval.id === id);
return {
ending: cardEnding(detail, turnId, id),
expires_at: row?.expires_at,
user_extends_left: row?.user_extends_left,
};
}
// The runtime outlives every container, so the list follows the active read on its own observer.
function watchActiveChats(org: string, onChats: (chats: ChatSummaryWire[]) => void): () => void {
return observe<ChatsPage>(assistantActiveChatsQuery(org), (result) => {
if (result.data) onChats(result.data.chats);
});
}
// Getters, because the list is built before anything calls it and reads the runtime lazily.
function runsDeps(
context: ShallowRef<AssistantRuntimeContext | null>,
store: () => AssistantStoreInternal,
capability: () => AssistantCapabilityHandle,
lifecycle: () => AssistantLifecycleRuntime,
hints: AssistantHintChannel,
): RunsDeps {
return {
store,
hints,
remote: () => lifecycle().remoteTurns(),
now: () => Date.now(),
org: () => context.value?.getOrg()?.identifier ?? null,
clientId: assistantClientId,
serverMode: () => isAssistantV2() && capability().capability.value.persistence,
watchActive: watchActiveChats,
invalidateActive: (org) =>
void queryClient.invalidateQueries({ queryKey: assistantKeys.activeChats(org) }),
reveal: (invoker) =>
getAssistantRuntime().openAssistant({
entryKind: "header_button",
entryVerb: "ask",
objectKind: "none",
intent: "open",
thread: "current",
invoker,
}),
focusCard: assistantFocusCard,
stopElsewhere: (sid) => lifecycle().stopElsewhere(sid),
approvalElsewhere: (sid) => lifecycle().approvalElsewhere(sid),
};
}
// History is built after the store and the capability, which take it lazily through `deps`.
function historyDeps(
context: ShallowRef<AssistantRuntimeContext | null>,
@@ -171,6 +171,7 @@ const OUTCOME_ACTIONS: ReadonlySet<string> = new Set<AssistantErrorAction>([
"ask_again",
"send_again",
"change_level",
"show_running",
"none",
]);
@@ -433,6 +434,7 @@ export function errorTitle(
params.agent = agentName(agentIdOf(error.agent) ?? DEFAULT_AGENT, copy.t);
if (copy.t && error.handoffFrom)
params.fromAgent = agentName(agentIdOf(error.handoffFrom) ?? DEFAULT_AGENT, copy.t);
if (error.limit !== undefined) params.n = error.limit;
// A `retry_after` countdown is on the button, so the title gives no estimate of its own.
if (error.code === "rate_limited" && error.retryAfterMs)
return { key: "assistant.error.rate_limited.titleTimed", params };
@@ -837,7 +839,8 @@ function lifecycleLine(
return t("assistant.turn.retrying", { n: retrying.attempt });
}
function elapsedText(ms: number, t: TranslateFn): I18nText {
/** "12 s" or "1 min 5 s", as every running status says it. */
export function elapsedText(ms: number, t: TranslateFn): I18nText {
const total = Math.floor(ms / 1000);
if (total < 60) return t("assistant.turn.elapsedSeconds", { n: total });
return t("assistant.turn.elapsedMinutes", { m: Math.floor(total / 60), s: total % 60 });
@@ -379,10 +379,48 @@ describe("useAssistantApprovals", () => {
await expect(plain.approvals.extend(plain.sid)).resolves.toBe(false);
const more = setup(PERMISSION, true);
await expect(more.approvals.extend(more.sid)).resolves.toBe(true);
expect(more.extend).toHaveBeenCalledWith("org1", more.sid, "per_1");
expect(more.extend).toHaveBeenCalledWith("org1", more.sid, "per_1", "user");
expect(more.session.pendingApproval?.expiresAt).toBe(now + 300_000);
});
it("records the user's budget from the answer, and a refusal ends More time but not the card", async () => {
const { sid, approvals, extend, session } = setup(PERMISSION, true);
extend.mockResolvedValueOnce({ expires_at: now + 300_000, user_extends_left: 9 } as never);
await approvals.extend(sid);
expect(session.pendingApproval?.userExtendsLeft).toBe(9);
extend.mockRejectedValueOnce({
response: { status: 409, data: { code: "extend_limit", by: "user" } },
});
await expect(approvals.extend(sid)).resolves.toBe(false);
expect(session.pendingApproval?.userExtendsLeft).toBe(0);
expect(session.pendingApproval?.expiresAt).toBe(now + 300_000);
});
it("extends out of view from the background budget and reports what came of it", async () => {
const { sid, approvals, extend, session } = setup(PERMISSION, true);
const id = held(sid)!;
await expect(approvals.extendInBackground(sid, id)).resolves.toBe("extended");
expect(extend).toHaveBeenCalledWith("org1", sid, id, "background");
extend.mockRejectedValueOnce({
response: { status: 409, data: { code: "extend_limit", by: "background" } },
});
await expect(approvals.extendInBackground(sid, id)).resolves.toBe("spent");
expect(session.pendingApproval?.id).toBe(id);
extend.mockRejectedValueOnce(new Error("offline"));
await expect(approvals.extendInBackground(sid, id)).resolves.toBe("failed");
expect(session.pendingApproval?.id).toBe(id);
await expect(approvals.extendInBackground(sid, "other")).resolves.toBe("failed");
});
it("ends the card here when the server says it already expired", async () => {
const { sid, approvals, extend, session } = setup(PERMISSION, true);
const id = held(sid)!;
extend.mockRejectedValueOnce({ response: { status: 409, data: { code: "approval_expired" } } });
await expect(approvals.extendInBackground(sid, id)).resolves.toBe("expired");
await vi.runAllTimersAsync();
expect(session.pendingApproval).toBeNull();
});
it("asks again in one short turn naming the expired card", async () => {
const { sid, approvals, resend } = setup();
await approvals.askAgain(sid, "turn-1:1");
@@ -104,8 +104,10 @@ export interface ApprovalsDeps {
/** The registry's approvals-only dispatch: the one path that posts a decision or an undo. */
dispatch(sid: Sid, action: AssistantAction): Promise<ActionResult>;
/** The server's `extend`, outside the decision path: it decides nothing. */
extend(org: string, sid: Sid, id: string): Promise<ConfirmResponse>;
extend(org: string, sid: Sid, id: string, by: ExtendBudget): Promise<ConfirmResponse>;
extendable(): boolean;
/** The server's view of a card, read fresh; absent or `null` where it keeps no record. */
readCard?(org: string, sid: Sid, turnId: string, id: string): Promise<ServerCard | null>;
/** An approve or an undo changed objects of these types, so their open lists refresh. */
changed?(types: string[]): void;
hints: Pick<HintChannel, "on">;
@@ -114,6 +116,17 @@ export interface ApprovalsDeps {
t: TranslateFn;
}
/** Which of a card's two extension budgets a request spends (PO A11, B71). */
export type ExtendBudget = "user" | "background";
/** What an out-of-view extend came to; `spent` stops further ones, `failed` may be tried again. */
export type BackgroundExtend = "extended" | "spent" | "expired" | "failed";
/** A card as the chat's record has it: how it ended, else its current expiry and user budget. */
export interface ServerCard extends Pick<ConfirmResponse, "expires_at" | "user_extends_left"> {
ending: ApprovalOutcome["state"] | "unknown" | null;
}
/** `ApprovalsService` plus the record's own actions (Send again, Ask again, More time, Undo). */
export interface ApprovalsRuntime extends ApprovalsService {
/** Holds again a card a reload lost, until its own `expiresAt`; false when it has none left. */
@@ -121,6 +134,10 @@ export interface ApprovalsRuntime extends ApprovalsService {
/** Lets a restored card go with the record of how the server ended it, without a POST; `unknown` keeps no outcome. */
release(sid: Sid, approvalId: string, state: ApprovalOutcome["state"] | "unknown"): void;
extend(sid: Sid): Promise<boolean>;
/** The out-of-view extend, spending only the background budget. */
extendInBackground(sid: Sid, approvalId: string): Promise<BackgroundExtend>;
/** Re-reads the chat and adopts the server's expiry, or lets a card it ended go. */
reconcile(sid: Sid): Promise<void>;
sendAgain(sid: Sid, approvalId: string): Promise<void>;
askAgain(sid: Sid, approvalId: string): Promise<void>;
undo(sid: Sid, ledgerId: string): Promise<ActionResult>;
@@ -303,6 +320,19 @@ export function createApprovalsRuntime(deps: ApprovalsDeps): ApprovalsRuntime {
void run(sid, id, { approved: false, reason: "expired" });
};
// The server's expiry and extension count win; a card that changed meanwhile is never touched.
const adopt = (sid: Sid, id: string, response: ConfirmResponse): boolean => {
const until = epochMs(response.expires_at);
const current = sessionOf(sid)?.pendingApproval;
if (until === null || current?.id !== id) return false;
current.expiresAt = Math.min(until, deps.now() + MAX_CARD_MS);
if (typeof response.user_extends_left === "number")
current.userExtendsLeft = response.user_extends_left;
armTimer(sid, id, current.expiresAt);
deps.engine().approvalExtended?.(sid);
return true;
};
const offs: (() => void)[] = [
// The server's TTL decides what a signed-out tab left pending; nothing is approved or posted on the way out.
deps.hints.on("signed_out", () => {
@@ -395,18 +425,41 @@ export function createApprovalsRuntime(deps: ApprovalsDeps): ApprovalsRuntime {
const pending = session?.pendingApproval;
if (!session || !pending || !deps.extendable()) return false;
try {
const response = await deps.extend(session.org, pending.sessionId, pending.id);
const until = epochMs(response.expires_at);
const response = await deps.extend(session.org, pending.sessionId, pending.id, "user");
return adopt(sid, pending.id, response);
} catch (err) {
const current = sessionOf(sid)?.pendingApproval;
if (until === null || current?.id !== pending.id) return false;
current.expiresAt = Math.min(until, deps.now() + MAX_CARD_MS);
armTimer(sid, pending.id, current.expiresAt);
deps.engine().approvalExtended?.(sid);
return true;
} catch {
if (extendRefusal(err) === "spent" && current?.id === pending.id)
current.userExtendsLeft = 0;
return false;
}
},
async extendInBackground(sid, approvalId) {
const session = sessionOf(sid);
const pending = session?.pendingApproval;
if (!session || pending?.id !== approvalId) return "failed";
try {
const response = await deps.extend(session.org, sid, approvalId, "background");
return adopt(sid, approvalId, response) ? "extended" : "failed";
} catch (err) {
const refusal = extendRefusal(err);
if (refusal === "expired") expire(sid, approvalId);
return refusal;
}
},
async reconcile(sid) {
const session = sessionOf(sid);
const pending = session?.pendingApproval;
if (!session || !pending || !deps.readCard) return;
if (inFlight.has(keyOf(sid, pending.id))) return;
const card = await deps
.readCard(session.org, sid, pending.turnId, pending.id)
.catch(() => null);
if (!card || sessionOf(sid)?.pendingApproval?.id !== pending.id) return;
if (!card.ending) return void adopt(sid, pending.id, card);
runtime.release(sid, pending.id, card.ending);
deps.engine().approvalDecided?.(sid);
},
async sendAgain(sid, approvalId) {
const entry = retries.get(sid);
const session = sessionOf(sid);
@@ -448,6 +501,14 @@ export function approvalClock(ms: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** A card's time left in words, for what is spoken: "45 seconds", "4 minutes". */
export function approvalDuration(ms: number, t: TranslateFn): I18nText {
const seconds = Math.ceil(ms / 1000);
if (seconds < 120) return t("assistant.approval.seconds", { count: seconds }, seconds);
const minutes = Math.round(seconds / 60);
return t("assistant.approval.minutes", { count: minutes }, minutes);
}
/** The plural entity noun key of a card's targets; mixed or unknown types read "object". */
export function approvalEntity(card: Pick<ApprovalBlockData, "targets">): string {
// A redacted record keeps no targets.
@@ -732,6 +793,17 @@ function changedTypes(card: ApprovalBlockData, detail: ApprovalDetail | undefine
return [...new Set((items ?? card.targets).map((item) => item.type))];
}
// A refused extend names the budget it spent; only `approval_expired` and a card the server no longer holds end it here.
function extendRefusal(err: unknown): Exclude<BackgroundExtend, "extended"> {
const response = (err as { response?: { status?: number; data?: unknown } } | null)?.response;
const body = (response?.data ?? {}) as { code?: unknown; error_code?: unknown; error?: unknown };
const code = [body.code, body.error_code, body.error].find((v) => typeof v === "string");
if (code === "extend_limit") return "spent";
if (code === "approval_expired" || response?.status === 404 || response?.status === 410)
return "expired";
return "failed";
}
function epochMs(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
return value < 1e12 ? value * 1000 : value;
@@ -101,6 +101,21 @@ function setup(options: Setup = {}) {
const cap = (handle: AssistantCapabilityHandle) => handle.capability.value;
describe("approval_extend_budgets", () => {
it("reads either shape, and stays absent on an older server (PO A14)", async () => {
for (const budgets of [true, { user: 10, background: 3 }]) {
const { handle } = setup({
b06: { can_chat: true, approval_extend_budgets: budgets } as never,
});
await flushPromises();
expect(cap(handle).approvalExtendBudgets).toBe(true);
}
const { handle } = setup({ b06: { can_chat: true } as never });
await flushPromises();
expect(cap(handle).approvalExtendBudgets).toBeUndefined();
});
});
describe("gate order (10 §5)", () => {
it("stays loading until /config resolves and the user+org is known", async () => {
const { handle, config } = setup({ config: null });
@@ -352,6 +352,7 @@ function resolveCapability(input: CapabilityInput): AssistantCapability {
capability.retentionDays = input.b06.retention_days;
if (input.b06?.attachments === true) capability.attachments = true;
if (input.b06?.approval_extend === true) capability.approvalExtend = true;
if (input.b06?.approval_extend_budgets) capability.approvalExtendBudgets = true;
if (input.creditMode && input.edition.isCloud) capability.creditMode = input.creditMode;
if (input.b06?.history_search === true) capability.historySearch = true;
if (input.b06?.history_pins === true) capability.historyPins = true;
@@ -110,6 +110,20 @@ afterEach(() => {
});
describe("useAssistantClient: send", () => {
it("names this tab on a same-origin send only, so an older CORS list never fails the preflight", async () => {
const seen: Record<string, string>[] = [];
const fetchImpl = vi.fn(async (_url: unknown, init: RequestInit) => {
seen.push(init.headers as Record<string, string>);
return sse([{ type: "complete" }]);
}) as unknown as typeof fetch;
const send = (baseUrl: string) =>
useAssistantClient({ fetchImpl, baseUrl: () => baseUrl }).send(request(), sink(), signal());
await send(location.origin);
await send("https://api.example.com");
expect(seen[0]["x-o2-assistant-client-id"]).toBeTruthy();
expect(seen[1]["x-o2-assistant-client-id"]).toBeUndefined();
});
it("posts one chat_stream with the idempotency headers and credentials (00 §5.1)", async () => {
const fetchImpl = vi.fn().mockResolvedValue(sse([{ type: "complete" }]));
const client = useAssistantClient({ fetchImpl });
@@ -27,6 +27,7 @@ import { getUUID, getUUIDv7 } from "@/utils/uuid";
import { ERROR_LEDGER, toAssistantError } from "./assistantErrors";
import type { AssistantError, AssistantSession, SendRequest, Sid } from "./assistantStore.types";
import type { AssistantFrame } from "./frames";
import { assistantClientId } from "./historyElsewhere";
import { parseStream, type StreamEnd } from "./sseParser";
/** B-13 returns the trace id on admission; until then it comes from `complete`. */
@@ -169,7 +170,7 @@ export function useAssistantClient(opts: AssistantClientOptions = {}): Assistant
method: "POST",
body,
credentials: "include",
headers: streamHeaders(req.sid, req.turnId, true),
headers: { ...streamHeaders(req.sid, req.turnId, true), ...clientIdHeader(baseUrl()) },
signal,
});
return stream(request, sink, signal, req.turnId, "session_expired");
@@ -255,6 +256,13 @@ function toWireImages(images: ImageAttachment[]) {
return images.map((img) => ({ data: img.data, mime_type: img.mimeType, filename: img.filename }));
}
// A cross-origin preflight fails outright on a server whose CORS list predates this header, and the owner id is only an extra signal.
function clientIdHeader(base: string): Record<string, string> {
if (typeof location === "undefined" || new URL(base, location.href).origin !== location.origin)
return {};
return { "x-o2-assistant-client-id": assistantClientId() };
}
function streamHeaders(sid: Sid, turnId: string, json: boolean): Record<string, string> {
return {
...(json && { "Content-Type": "application/json" }),
@@ -480,6 +480,8 @@ describe("useAssistantHistory across tabs", () => {
await flushPromises();
await fake.history.open({ sid });
hints.fire({ type: "turn_started", sid, turnId: "t9", org: "default" });
expect(fake.store.state.sessions[sid].turnMeta.ownedByThisTab).toBe(true);
hints.fire({ type: "turn_heartbeat", sid, turnId: "t9", org: "default" });
expect(fake.store.state.sessions[sid].turnMeta.ownedByThisTab).toBe(false);
await vi.waitFor(
() => expect(fake.store.state.sessions[sid].turnMeta.ownedByThisTab).toBe(true),
+69 -2
View File
@@ -9249,6 +9249,11 @@
"announce": {
"approval": "Waiting for your approval: {title}.",
"approvalExpires": "Waiting for your approval: {title}. Expires in {duration}.",
"approvalExpiring": "Approval expires in {duration}: {title}. More time is on the card.",
"approvalExpiringFinal": "Approval expires in {duration}: {title}. No more time can be added.",
"approvalExpiringNotice": "Approval expires in {duration}: {title}. Review it from the {product} notice.",
"approvalExpiringNoticeFinal": "Approval expires in {duration}: {title}. No more time can be added. Review it from the {product} notice.",
"approvalExtended": "More time added. Expires in {duration}.",
"approvalJump": "{line} Press {keys} to review it.",
"approvalJumpTouch": "{line} Review request is above the message box.",
"closed": "{product} closed.",
@@ -9302,7 +9307,8 @@
"effect": "Effect",
"escapeHint": "Esc declines",
"expiresIn": "Expires in {time}",
"expiresSoon": "This request expires in 1 minute.",
"expiringSoon": "Expires soon · {time}",
"gateTiming": "Approving needs a newer {product} agent. Nothing changes unless you approve; ask an admin to update the agent.",
"heading": {
"delete": "Delete {count} {entity}",
"permission": "Allow {tool}",
@@ -9310,6 +9316,7 @@
},
"inOrg": "in {org}",
"minutes": "{count} minute | {count} minutes",
"lastExtension": "No more time can be added.",
"moreTargets": "and {count} more",
"moreTime": "More time",
"named": "{verb} {name}",
@@ -9997,6 +10004,15 @@
"title": "The AI provider is limiting requests. Your message wasn't sent.",
"titleTimed": "The AI provider is limiting requests. Your message wasn't sent."
},
"run_limit_member": {
"action": "Show running chats",
"body": "Stop one, or send when one ends.",
"title": "{n} of your chats are running or waiting for you. Your message wasn't sent."
},
"run_limit_org": {
"action": "Retry",
"title": "{product} is busy for your organization. Your message wasn't sent."
},
"restore_required": {
"action": "Reload chat",
"title": "This chat was reset on the server. Reload to continue."
@@ -10322,7 +10338,6 @@
"stateOpen": "{product} · Open",
"stateRunningCount": "{count} answer running | {count} answers running",
"stateWorking": "Answering: {title}",
"toastNeedsYou": "Waiting for your approval · {title}",
"toastReady": "Answer ready · {title}",
"tooltip": "{product}",
"unavailableName": "{product}, unavailable"
@@ -10648,6 +10663,15 @@
"metaHandoff": "{meta} · to {agent}"
},
"name": "O2 Assistant",
"limit": {
"slotFree": "A chat finished. You can send now."
},
"notice": {
"decision": "{product} needs your decision",
"decisionMany": "{n} chats need your decision",
"hide": "Hide notice",
"region": "{product} notice"
},
"outcome": {
"actions": {
"askAdmin": "Ask an admin",
@@ -10895,6 +10919,49 @@
"v2OptinDeployment": "Turned on for this deployment, so this browser can't turn it off.",
"v2OptinHelp": "Reload the page to switch."
},
"runs": {
"agent": {
"general": "{product}",
"investigator": "Investigator",
"queryBuilder": "Query builder"
},
"back": "Back to the chat",
"button": "{count} other chat: {summary} | {count} other chats: {summary}",
"capacity": "{active} of {cap} running chats",
"chatInOrg": "A chat in {org}",
"clear": "Clear from this list",
"clearFinished": "Clear finished",
"footer": "Finished chats leave this list when you open them",
"heading": "Chats in progress",
"inOrg": "In {org}",
"openFull": "Open in full view",
"openHere": "Open here",
"review": "Review",
"reviewName": "Review {title}",
"rowActions": "Actions for {title}",
"rowExpires": "{title}, {agent}, {status}, {time} left",
"rowMeta": "{agent} · {status}",
"rowName": "{title}, {agent}, {status}",
"state": {
"answering": "Answering",
"elsewhere": "Answering in another tab",
"failed": "Answer failed",
"needsApproval": "Needs your approval",
"ready": "Answer ready",
"reconnecting": "Reconnecting",
"stopped": "Stopped",
"working": "Answering · {elapsed}"
},
"stop": "Stop",
"stopName": "Stop {title}",
"summary": {
"answering": "{count} answering",
"failed": "{count} failed",
"needs": "{count} needs you | {count} need you",
"ready": "{count} ready"
},
"thisChat": "This chat"
},
"share": {
"allOrgAdminOnly": "Only organization admins can see every shared chat.",
"allLinks": "All shared links",
+12 -1
View File
@@ -18,7 +18,7 @@ import { describe, expect, it, vi } from "vitest";
import { SESSION_STALE_TIME } from "@/composables/query/cachePolicy";
import assistant from "./assistant";
import { capabilitiesQuery } from "./assistant.queries";
import { assistantActiveChatsQuery, capabilitiesQuery } from "./assistant.queries";
import { assistantKeys } from "./assistant.querykeys";
vi.mock("./assistant", () => ({ default: { capabilities: vi.fn().mockResolvedValue(null) } }));
@@ -45,6 +45,17 @@ describe("capabilitiesQuery", () => {
});
});
describe("assistantActiveChatsQuery", () => {
it("polls within the light's 15 s bound, in its own key beside the chats list", () => {
const options = assistantActiveChatsQuery("org1");
expect(options.queryKey).toEqual(assistantKeys.activeChats("org1"));
expect(options.queryKey.slice(0, -1)).toEqual(assistantKeys.chats("org1"));
expect(options.refetchInterval).toBe(15_000);
expect(options.retry).toBe(false);
expect(assistantActiveChatsQuery("").enabled).toBe(false);
});
});
describe("assistantKeys", () => {
it("roots every key at the org so the org purge and logout sweep them", () => {
const keys = [
+15
View File
@@ -20,6 +20,9 @@ import { LIVE_STALE_TIME, SESSION_STALE_TIME } from "@/composables/query/cachePo
import assistant, { type ChatDetail } from "./assistant";
import { assistantKeys } from "./assistant.querykeys";
// The header light's own bound on how stale another device's chats may be (`00` §12.2).
const ACTIVE_CHATS_POLL_MS = 15_000;
/** B-06 capabilities; `enabled` is false on an unentitled or unconfigured build, so nothing is requested there. */
export const capabilitiesQuery = (org: string, enabled = true) =>
queryOptions({
@@ -53,6 +56,18 @@ export const assistantChatsQuery = (org: string, cursor?: string, q?: string) =>
enabled: !!org,
});
/** The chats-in-progress read; it polls only while the page is visible (`00` §12.2). */
export const assistantActiveChatsQuery = (org: string) =>
queryOptions({
queryKey: assistantKeys.activeChats(org),
queryFn: () => assistant.listActiveChats(org),
staleTime: LIVE_STALE_TIME,
refetchInterval: ACTIVE_CHATS_POLL_MS,
refetchOnWindowFocus: true,
retry: false,
enabled: !!org,
});
/** Sends what the cache holds, so an unchanged chat costs no turns and a stale one only its newer turns. */
export const assistantChatQuery = (
org: string,
+1
View File
@@ -20,6 +20,7 @@ export const assistantKeys = {
capabilities: (org: string) => orgKey(org, "assistant", "capabilities"),
chat: (org: string, sid: string) => orgKey(org, "assistant", "chat", sid),
chats: (org: string) => orgKey(org, "assistant", "chats"),
activeChats: (org: string) => orgKey(org, "assistant", "chats", "active"),
settings: (org: string) => orgKey(org, "assistant", "settings"),
usage: (org: string) => orgKey(org, "assistant", "usage"),
};
+44 -1
View File
@@ -177,9 +177,52 @@ describe("assistant service", () => {
});
});
describe("listActiveChats", () => {
it("asks for the active state and keeps the fields the list reads", async () => {
const get = vi.fn(async () => ({
data: {
chats: [
{
session_id: "s1",
title: "t",
updated_at: 1,
last_turn_status: "completed",
last_turn_ended_at: 2,
seen: false,
owner_client_id: "c1",
},
],
},
}));
(http as any).mockReturnValue({ get });
const page = await assistant.listActiveChats("org 1");
expect(get).toHaveBeenCalledWith(
"/api/org%201/ai/chats",
expect.objectContaining({ params: expect.objectContaining({ state: "active" }) }),
);
expect(page.chats[0]).toMatchObject({
last_turn_status: "completed",
last_turn_ended_at: 2,
seen: false,
owner_client_id: "c1",
});
});
});
describe("parseChatDetail approvals", () => {
const detail = (approvals: unknown) => parseChatDetail({ session_id: "s1", approvals });
it("carries the server's expiry and user budget, and drops a malformed one", () => {
const parsed = detail([
{ id: "a", status: "pending", expires_at: 5, user_extends_left: 9 },
{ id: "b", status: "pending", expires_at: "soon", user_extends_left: null },
]);
expect(parsed.approvals).toEqual([
{ id: "a", status: "pending", expires_at: 5, user_extends_left: 9 },
{ id: "b", status: "pending" },
]);
});
it("reads each approval row's id, status and decision time", () => {
const parsed = detail([
{
@@ -194,7 +237,7 @@ describe("parseChatDetail approvals", () => {
{ id: "apr_3", status: "denied", resolved: { at: 0 } },
]);
expect(parsed.approvals).toEqual([
{ id: "apr_1", status: "approved", at: 1_700_000_000_000 },
{ id: "apr_1", status: "approved", at: 1_700_000_000_000, expires_at: 1 },
{ id: "apr_2", status: "expired" },
{ id: "apr_3", status: "denied" },
]);
+41 -2
View File
@@ -46,6 +46,8 @@ export interface AssistantCapabilitiesResponse {
attachments?: boolean;
/** `/confirm {id, extend:true}` resets a card's expiry. */
approval_extend?: boolean;
/** Split user and background extension budgets (W-10); the `{user, background}` sizes when given. */
approval_extend_budgets?: boolean | { user?: number; background?: number };
context_used?: boolean;
answer_locale?: boolean;
history_search?: boolean;
@@ -84,6 +86,8 @@ export interface ConfirmBody {
/** Asks for more time on the card `id` instead of deciding. */
id?: string;
extend?: boolean;
/** Which budget an extend spends; absent reads as `user`. */
by?: "user" | "background";
}
/** o2-ai answers a delete batch item by item; a permission or CLI answer only echoes the decision. */
@@ -98,6 +102,8 @@ export interface ConfirmResponse {
replied?: boolean;
/** The card's new expiry after `extend`. */
expires_at?: number;
user_extends_left?: number;
background_extends_left?: number;
}
/** The restored object, when the server's action ledger can undo an entry. */
@@ -133,6 +139,13 @@ export interface ChatSummaryWire {
pinned?: boolean;
agent_type?: string | null;
origin?: string;
/** `state=active` (W-11); each absent on a server without it. */
last_turn_status?: string | null;
/** Microseconds. */
last_turn_ended_at?: number | null;
seen?: boolean;
/** The `x-o2-assistant-client-id` of the tab that runs the active turn. */
owner_client_id?: string | null;
}
export interface ChatsPage {
@@ -160,6 +173,9 @@ export interface ChatApproval {
status: "pending" | "approved" | "denied" | "expired" | "withdrawn";
/** Epoch ms of the decision, from the stored `confirmation_resolved`. */
at?: number;
/** The expiry the server holds now, so a returning tab never counts down a stale one. */
expires_at?: number;
user_extends_left?: number;
}
export interface ChatImportItem {
@@ -266,11 +282,17 @@ const chatSummarySchema = z.object({
pinned: z.boolean().optional().catch(undefined),
agent_type: z.string().max(64).nullable().optional().catch(undefined),
origin: z.string().max(32).optional().catch(undefined),
last_turn_status: z.string().max(32).nullable().optional().catch(undefined),
last_turn_ended_at: z.number().nullable().optional().catch(undefined),
seen: z.boolean().optional().catch(undefined),
owner_client_id: z.string().max(128).nullable().optional().catch(undefined),
});
const chatApprovalSchema = z.object({
id: z.string().min(1).max(128),
status: z.enum(["pending", "approved", "denied", "expired"]),
expires_at: z.number().optional().catch(undefined),
user_extends_left: z.number().optional().catch(undefined),
resolved: z
.object({
at: z.number().optional().catch(undefined),
@@ -356,7 +378,7 @@ export function parseChatDetail(data: unknown): ChatDetail {
function chatApprovalOf(value: unknown): ChatApproval[] {
const parsed = chatApprovalSchema.safeParse(value);
if (!parsed.success) return [];
const { id, resolved } = parsed.data;
const { id, resolved, expires_at, user_extends_left } = parsed.data;
const at = resolved?.at;
const status =
parsed.data.status !== "denied"
@@ -366,7 +388,15 @@ function chatApprovalOf(value: unknown): ChatApproval[] {
: resolved?.expired === true
? "expired"
: "denied";
return [{ id, status, ...(typeof at === "number" && at > 0 && { at }) }];
return [
{
id,
status,
...(typeof at === "number" && at > 0 && { at }),
...(expires_at !== undefined && { expires_at }),
...(user_extends_left !== undefined && { user_extends_left }),
},
];
}
const assistant = {
@@ -483,6 +513,15 @@ const assistant = {
return parseChatsPage(res.data);
},
/** Running, waiting and recently finished chats; a server without `state` answers its plain first page. */
listActiveChats: async (org: string): Promise<ChatsPage> => {
const res = await http().get<ChatsPage>(`/api/${enc(org)}/ai/chats`, {
...ASSISTANT_REQUEST,
params: { limit: String(CHATS_PAGE), state: "active" },
});
return parseChatsPage(res.data);
},
/** With `known`, an unchanged chat answers `not_modified` and a stale one only its newer turns. */
getChat: async (
org: string,