mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
merge(assistant): runs lane
Claude-Session: https://claude.ai/code/session_017PUuNbanUSUUbZ5dcZBYmM
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -162,6 +162,7 @@ export {
|
||||
} from "./approvalAnatomy";
|
||||
export {
|
||||
approvalClock,
|
||||
approvalDuration,
|
||||
approvalEntity,
|
||||
approvalHeading,
|
||||
approvalNoun,
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user