mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
3 Commits
fix/chat-i
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ba3e440f | ||
|
|
dd148b9b65 | ||
|
|
9e0614612d |
@@ -1,234 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { shouldDropException } from "./exception-dedupe";
|
||||
|
||||
const STORAGE_KEY = "mc_exc_fp";
|
||||
|
||||
// In-memory sessionStorage stand-in. Optional flags let a test force getItem /
|
||||
// setItem to throw (quota, disabled storage) so we can assert the fail-open
|
||||
// direction.
|
||||
function makeStorage(opts: { throwOnGet?: boolean; throwOnSet?: boolean } = {}) {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
data,
|
||||
getItem(k: string): string | null {
|
||||
if (opts.throwOnGet) throw new Error("getItem blocked");
|
||||
return data.has(k) ? data.get(k)! : null;
|
||||
},
|
||||
setItem(k: string, v: string): void {
|
||||
if (opts.throwOnSet) throw new Error("quota exceeded");
|
||||
data.set(k, v);
|
||||
},
|
||||
removeItem(k: string): void {
|
||||
data.delete(k);
|
||||
},
|
||||
clear(): void {
|
||||
data.clear();
|
||||
},
|
||||
key(i: number): string | null {
|
||||
return Array.from(data.keys())[i] ?? null;
|
||||
},
|
||||
get length(): number {
|
||||
return data.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build a redacted-shape `$exception` properties object. By the time dedupe
|
||||
// runs, redactExceptionProperties has already scrubbed value/message.
|
||||
function exc(o: {
|
||||
type?: string;
|
||||
value?: string;
|
||||
frames?: Array<Record<string, unknown>> | null;
|
||||
} = {}): Record<string, unknown> {
|
||||
const entry: Record<string, unknown> = {
|
||||
type: o.type ?? "TypeError",
|
||||
value: o.value ?? "boom",
|
||||
};
|
||||
if (o.frames !== null) {
|
||||
entry.stacktrace = {
|
||||
type: "raw",
|
||||
frames: o.frames ?? [
|
||||
{ filename: "app.tsx", function: "render", lineno: 10, colno: 5 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { $exception_list: [entry] };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldDropException — per-fingerprint limit", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage());
|
||||
});
|
||||
|
||||
it("keeps the first 3 of a fingerprint and drops from the 4th", () => {
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(true);
|
||||
expect(shouldDropException(exc())).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different fingerprints independently — one does not drop the other", () => {
|
||||
// Exhaust fingerprint A.
|
||||
const a = () => exc({ type: "TypeError", value: "a" });
|
||||
const b = () => exc({ type: "RangeError", value: "b" });
|
||||
shouldDropException(a());
|
||||
shouldDropException(a());
|
||||
shouldDropException(a());
|
||||
expect(shouldDropException(a())).toBe(true); // A fused
|
||||
// B is untouched.
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(true);
|
||||
});
|
||||
|
||||
it("discriminates on colno (minified bundles collapse statements onto one line)", () => {
|
||||
const at = (colno: number) =>
|
||||
exc({ frames: [{ filename: "b.js", function: "x", lineno: 1, colno }] });
|
||||
// Same file/line/function, different column → distinct fingerprints, so
|
||||
// each keeps its own first-3 budget.
|
||||
shouldDropException(at(10));
|
||||
shouldDropException(at(10));
|
||||
shouldDropException(at(10));
|
||||
expect(shouldDropException(at(10))).toBe(true);
|
||||
expect(shouldDropException(at(20))).toBe(false);
|
||||
});
|
||||
|
||||
it("stores only a hash + counter — no raw value reaches storage", () => {
|
||||
const storage = makeStorage();
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
shouldDropException(exc({ value: "secret-marker-12345" }));
|
||||
const blob = storage.data.get(STORAGE_KEY) ?? "";
|
||||
expect(blob).not.toContain("secret-marker-12345");
|
||||
expect(blob).not.toContain("app.tsx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — degraded frames", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage());
|
||||
});
|
||||
|
||||
it("tolerates missing lineno/colno/function and still dedupes", () => {
|
||||
const partial = () => exc({ frames: [{ filename: "only-file.js" }] });
|
||||
expect(() => shouldDropException(partial())).not.toThrow();
|
||||
shouldDropException(partial());
|
||||
shouldDropException(partial());
|
||||
expect(shouldDropException(partial())).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates no stacktrace at all (fingerprints on type + value)", () => {
|
||||
const noframes = () => exc({ frames: null });
|
||||
shouldDropException(noframes());
|
||||
shouldDropException(noframes());
|
||||
shouldDropException(noframes());
|
||||
expect(shouldDropException(noframes())).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps events with no usable signal (empty type/value/frames)", () => {
|
||||
const empty = { $exception_list: [{ type: "", value: "" }] };
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false); // never fused — no fingerprint
|
||||
});
|
||||
|
||||
it("is safe on undefined / malformed properties", () => {
|
||||
expect(shouldDropException(undefined)).toBe(false);
|
||||
expect(
|
||||
shouldDropException({ $exception_list: "nope" as unknown as [] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — storage fail-open", () => {
|
||||
it("fails open when sessionStorage is undefined (SSR)", () => {
|
||||
vi.stubGlobal("sessionStorage", undefined);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open when accessing sessionStorage throws (sandboxed iframe)", () => {
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("blocked by sandbox");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(() => shouldDropException(exc())).not.toThrow();
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
} finally {
|
||||
// Remove the throwing getter so it doesn't leak into other tests.
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails open when getItem throws", () => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage({ throwOnGet: true }));
|
||||
expect(() => shouldDropException(exc())).not.toThrow();
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open on a corrupted JSON blob and re-seeds clean state", () => {
|
||||
const storage = makeStorage();
|
||||
storage.data.set(STORAGE_KEY, "{not valid json");
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
// Blob is now valid JSON again with this fingerprint counted once.
|
||||
const reseeded = JSON.parse(storage.data.get(STORAGE_KEY)!);
|
||||
expect(typeof reseeded).toBe("object");
|
||||
expect(Object.values(reseeded)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("setItem failure under-counts (fewer drops), never over-drops", () => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage({ throwOnSet: true }));
|
||||
// Persisting the increment always fails, so the counter never advances and
|
||||
// no event is ever dropped — the required "less drop" direction.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — distinct-fingerprint cap", () => {
|
||||
it("keeps (does not track) a new fingerprint once the cap is reached", () => {
|
||||
const storage = makeStorage();
|
||||
// Seed 50 distinct fingerprints already at count 1.
|
||||
const seed: Record<string, number> = {};
|
||||
for (let i = 0; i < 50; i++) seed[`seed-${i}`] = 1;
|
||||
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
// The 51st, brand-new fingerprint is kept and NOT added to the blob.
|
||||
expect(shouldDropException(exc({ value: "fingerprint-51" }))).toBe(false);
|
||||
const after = JSON.parse(storage.data.get(STORAGE_KEY)!);
|
||||
expect(Object.keys(after)).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("still fuses a fingerprint that is already tracked at the cap", () => {
|
||||
const storage = makeStorage();
|
||||
const seed: Record<string, number> = {};
|
||||
for (let i = 0; i < 49; i++) seed[`seed-${i}`] = 1;
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
// Track a real one to reach 50 distinct, exhausting its budget.
|
||||
const target = () => exc({ value: "tracked-at-cap" });
|
||||
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
shouldDropException(target()); // 50th distinct, count 1
|
||||
shouldDropException(target()); // 2
|
||||
shouldDropException(target()); // 3
|
||||
expect(shouldDropException(target())).toBe(true); // fused despite cap
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
// Session-scoped dedupe / throttle for `$exception` events.
|
||||
//
|
||||
// Runs in posthog-js `before_send` AFTER `redactExceptionProperties`, so the
|
||||
// fingerprint is built purely from already-redacted fields — no raw message,
|
||||
// value, or PII is ever written to storage (only a hash + a small counter).
|
||||
//
|
||||
// The fuse: keep the first EXCEPTION_SAMPLE_LIMIT of each (tab-session,
|
||||
// fingerprint) pair and drop the rest. One runaway error — a render loop, a
|
||||
// polling fetch that keeps throwing — otherwise emits 100+ identical
|
||||
// `$exception` events per session (MUL-3331 / MUL-3330). Different fingerprints
|
||||
// never affect each other.
|
||||
//
|
||||
// Safety invariant (load-bearing): `before_send` must never throw — a throw
|
||||
// there breaks ALL event delivery — and every storage failure must fail OPEN.
|
||||
// When in doubt we KEEP the event: emitting a duplicate is cheap, silently
|
||||
// dropping a real first-occurrence error is not. setItem failures therefore
|
||||
// only ever under-count (fewer drops), never over-drop.
|
||||
//
|
||||
// Scope is the browser tab session (`sessionStorage`): cleared when the tab
|
||||
// closes, isolated per tab. This is intentionally NOT the posthog 30-min
|
||||
// session — see the dedupe discussion on MUL-3331.
|
||||
|
||||
const STORAGE_KEY = "mc_exc_fp";
|
||||
// Keep the first N of each fingerprint per session, drop from N+1.
|
||||
const EXCEPTION_SAMPLE_LIMIT = 3;
|
||||
// Cap distinct fingerprints tracked per session so a session that throws many
|
||||
// *different* errors can't grow the blob without bound. Past the cap, new
|
||||
// fingerprints are not tracked and fail open (kept).
|
||||
const MAX_FINGERPRINTS = 50;
|
||||
|
||||
type FingerprintCounts = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Decide whether this already-redacted `$exception` event should be dropped as
|
||||
* a session-level duplicate. Returns `true` to drop, `false` to keep.
|
||||
*
|
||||
* Never throws. Any missing fingerprint signal, unavailable/corrupt storage, or
|
||||
* unexpected error results in `false` (keep) — the fail-open direction.
|
||||
*/
|
||||
export function shouldDropException(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
const fingerprint = buildFingerprint(properties);
|
||||
// Nothing stable to dedupe on → keep.
|
||||
if (fingerprint === null) return false;
|
||||
|
||||
const storage = getSessionStorage();
|
||||
if (!storage) return false;
|
||||
|
||||
// The entire read-decide-write sequence is guarded: a throw anywhere (parse,
|
||||
// getItem, property access) degrades to keep.
|
||||
try {
|
||||
const counts = readCounts(storage);
|
||||
const current = typeof counts[fingerprint] === "number" ? counts[fingerprint] : 0;
|
||||
|
||||
// Already at the limit for this fingerprint → fuse blows, drop.
|
||||
if (current >= EXCEPTION_SAMPLE_LIMIT) return true;
|
||||
|
||||
// A brand-new fingerprint once the cap is reached: don't track it (would
|
||||
// grow the blob), and keep the event.
|
||||
if (current === 0 && Object.keys(counts).length >= MAX_FINGERPRINTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
counts[fingerprint] = current + 1;
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(counts));
|
||||
} catch {
|
||||
// Persisting the increment failed (quota / disabled). We still keep this
|
||||
// event (return false below). The unpersisted increment only means the
|
||||
// next identical error is also kept — under-counting toward the limit,
|
||||
// i.e. fewer drops, never more. This is the required failure direction.
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the counts blob. A corrupt or unexpected payload is
|
||||
* treated as empty (fail open — this event is kept and re-seeds the blob). */
|
||||
function readCounts(storage: Storage): FingerprintCounts {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as FingerprintCounts;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON blob → start fresh.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable fingerprint from the redacted exception properties. Uses the
|
||||
* exception type, the redacted message/value, and a single deterministic stack
|
||||
* frame. Returns `null` when there's nothing stable to key on (keep the event).
|
||||
*
|
||||
* Every frame field (`function` / `lineno` / `colno`) is treated as optional
|
||||
* and degrades to empty — minified or partial stacks must not throw or collapse
|
||||
* every error into one bucket via an undefined access.
|
||||
*/
|
||||
function buildFingerprint(properties: Record<string, unknown> | undefined): string | null {
|
||||
if (!properties || typeof properties !== "object") return null;
|
||||
|
||||
const list = properties.$exception_list;
|
||||
const entry =
|
||||
Array.isArray(list) && list.length > 0 && list[0] && typeof list[0] === "object"
|
||||
? (list[0] as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const type = readString(entry?.type) ?? readString(properties.$exception_type) ?? "";
|
||||
const value =
|
||||
readString(entry?.value) ?? readString(properties.$exception_message) ?? "";
|
||||
const frame = topFrame(entry);
|
||||
|
||||
// No signal at all → don't dedupe.
|
||||
if (type === "" && value === "" && !frame) return null;
|
||||
|
||||
const parts = [type, value];
|
||||
if (frame) {
|
||||
// colno is kept (load-bearing): minified bundles collapse many statements
|
||||
// onto one line, so line alone under-discriminates distinct errors.
|
||||
parts.push(frame.filename, frame.fn, frame.lineno, frame.colno);
|
||||
}
|
||||
return hash(parts.join(""));
|
||||
}
|
||||
|
||||
interface TopFrame {
|
||||
filename: string;
|
||||
fn: string;
|
||||
lineno: string;
|
||||
colno: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single deterministic stack frame for fingerprinting. We always take
|
||||
* the LAST frame in the array — a fixed end, with NO engine/order detection.
|
||||
* The same error within a session yields the same frames array and therefore
|
||||
* the same chosen frame, which is all the fingerprint needs; we don't care
|
||||
* which end is semantically "topmost". Missing pieces degrade to "".
|
||||
*/
|
||||
function topFrame(entry: Record<string, unknown> | undefined): TopFrame | null {
|
||||
if (!entry) return null;
|
||||
const stacktrace = entry.stacktrace;
|
||||
const frames =
|
||||
stacktrace && typeof stacktrace === "object"
|
||||
? (stacktrace as Record<string, unknown>).frames
|
||||
: undefined;
|
||||
if (!Array.isArray(frames) || frames.length === 0) return null;
|
||||
|
||||
const f = frames[frames.length - 1];
|
||||
if (!f || typeof f !== "object") return null;
|
||||
const frame = f as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
filename: readString(frame.filename) ?? "",
|
||||
fn: readString(frame.function) ?? "",
|
||||
lineno: readNumberAsString(frame.lineno) ?? "",
|
||||
colno: readNumberAsString(frame.colno) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function readNumberAsString(v: unknown): string | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? String(v) : undefined;
|
||||
}
|
||||
|
||||
/** djb2 — a tiny stable string hash. Only used to bound the storage-key length;
|
||||
* collision risk across a single tab session's exceptions is negligible. */
|
||||
function hash(input: string): string {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h = ((h << 5) + h) ^ input.charCodeAt(i);
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/** Resolve `sessionStorage`, returning `null` if it is absent (SSR) or throws
|
||||
* on access (sandboxed iframe, storage disabled). */
|
||||
function getSessionStorage(): Storage | null {
|
||||
try {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
return sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -216,75 +216,3 @@ describe("captureException", () => {
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_send $exception pipeline", () => {
|
||||
// before_send is registered inside posthog.init's config; pull it back out of
|
||||
// the mock and drive it directly. Dedupe needs a working sessionStorage.
|
||||
function makeMemoryStorage() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void data.set(k, v),
|
||||
removeItem: (k: string) => void data.delete(k),
|
||||
clear: () => data.clear(),
|
||||
key: (i: number) => Array.from(data.keys())[i] ?? null,
|
||||
get length() {
|
||||
return data.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BeforeSend = (
|
||||
e: { event: string; properties: Record<string, unknown> } | null,
|
||||
) => unknown;
|
||||
|
||||
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
|
||||
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
|
||||
return config.before_send;
|
||||
}
|
||||
|
||||
function excEvent() {
|
||||
return {
|
||||
event: "$exception",
|
||||
properties: {
|
||||
$exception_list: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "Bad email bob@corp.com",
|
||||
stacktrace: {
|
||||
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeMemoryStorage());
|
||||
});
|
||||
|
||||
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const beforeSend = getBeforeSend(posthog);
|
||||
|
||||
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
|
||||
// Redaction still runs before the fuse.
|
||||
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
|
||||
|
||||
expect(beforeSend(excEvent())).not.toBeNull();
|
||||
expect(beforeSend(excEvent())).not.toBeNull();
|
||||
// 4th identical exception is dropped.
|
||||
expect(beforeSend(excEvent())).toBeNull();
|
||||
});
|
||||
|
||||
it("passes non-$exception events through untouched", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const beforeSend = getBeforeSend(posthog);
|
||||
|
||||
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
|
||||
expect(beforeSend(evt)).toBe(evt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { redactExceptionProperties } from "./redact-exception";
|
||||
import { shouldDropException } from "./exception-dedupe";
|
||||
|
||||
export const EVENT_SCHEMA_VERSION = 2;
|
||||
|
||||
@@ -157,17 +156,10 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
// typed value, a URL with a token), so `before_send` scrubs the message
|
||||
// and `$exception_list[].value` before the event leaves the client. Stack
|
||||
// frames (code locations) are kept. See redact-exception.ts.
|
||||
//
|
||||
// After scrubbing, a session-level fuse drops repeats of the same error so
|
||||
// a render loop or a polling fetch that keeps throwing can't emit 100+
|
||||
// identical `$exception` events per session (MUL-3331). The fingerprint is
|
||||
// built only from the already-redacted fields, so no PII reaches storage.
|
||||
// Order matters: redact first, then fingerprint the redacted shape.
|
||||
capture_exceptions: true,
|
||||
before_send: (event) => {
|
||||
if (event && event.event === "$exception") {
|
||||
redactExceptionProperties(event.properties);
|
||||
if (shouldDropException(event.properties)) return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
|
||||
@@ -467,9 +467,6 @@ const CancelledChatMessageSchema = z.object({
|
||||
message_id: z.string(),
|
||||
content: z.string(),
|
||||
restore_to_input: z.boolean().default(false),
|
||||
// Attachments detached from the deleted message so a restored draft can
|
||||
// re-bind them on re-send. Absent on servers that predate the field.
|
||||
attachments: z.array(AttachmentSchema).optional(),
|
||||
}).loose();
|
||||
|
||||
export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createChatStore, newSessionDraftKey } from "./store";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import type { Attachment } from "../types";
|
||||
|
||||
function memStorage(): StorageAdapter {
|
||||
const m = new Map<string, string>();
|
||||
@@ -16,26 +15,6 @@ function memStorage(): StorageAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
function makeAttachment(id: string): Attachment {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
filename: `${id}.png`,
|
||||
url: `/uploads/${id}.png`,
|
||||
download_url: `/api/attachments/${id}/download`,
|
||||
markdown_url: `/api/attachments/${id}/download`,
|
||||
content_type: "image/png",
|
||||
size_bytes: 1,
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("newSessionDraftKey", () => {
|
||||
it("derives a stable per-agent slot for an uncreated chat", () => {
|
||||
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
|
||||
@@ -43,31 +22,38 @@ describe("newSessionDraftKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat store — draft attachments", () => {
|
||||
describe("chat store — migrateInputDraft", () => {
|
||||
let store: ReturnType<typeof createChatStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createChatStore({ storage: memStorage() });
|
||||
});
|
||||
|
||||
it("deduplicates attachment drafts by id", () => {
|
||||
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
|
||||
store.getState().addInputDraftAttachment("draft-1", {
|
||||
...makeAttachment("att-1"),
|
||||
filename: "updated.png",
|
||||
});
|
||||
it("moves a draft to the new key and clears the source", () => {
|
||||
const from = newSessionDraftKey("agent-1");
|
||||
store.getState().setInputDraft(from, "!file[x.pdf]()");
|
||||
|
||||
expect(store.getState().inputDraftAttachments["draft-1"]).toHaveLength(1);
|
||||
expect(store.getState().inputDraftAttachments["draft-1"]?.[0]?.filename).toBe("updated.png");
|
||||
store.getState().migrateInputDraft(from, "session-1");
|
||||
|
||||
const drafts = store.getState().inputDrafts;
|
||||
expect(drafts["session-1"]).toBe("!file[x.pdf]()");
|
||||
// Source slot is cleared so it can't resurface in the next new chat.
|
||||
expect(from in drafts).toBe(false);
|
||||
});
|
||||
|
||||
it("clearInputDraft clears both text and attachment records", () => {
|
||||
store.getState().setInputDraft("draft-1", "hello");
|
||||
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
|
||||
it("is a no-op when the source draft is absent", () => {
|
||||
store.getState().setInputDraft("session-1", "keep me");
|
||||
|
||||
store.getState().clearInputDraft("draft-1");
|
||||
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
|
||||
|
||||
expect(store.getState().inputDrafts["draft-1"]).toBeUndefined();
|
||||
expect(store.getState().inputDraftAttachments["draft-1"]).toBeUndefined();
|
||||
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
|
||||
});
|
||||
|
||||
it("is a no-op when from === to", () => {
|
||||
store.getState().setInputDraft("session-1", "keep me");
|
||||
|
||||
store.getState().migrateInputDraft("session-1", "session-1");
|
||||
|
||||
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import type { Attachment } from "../types/attachment";
|
||||
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
@@ -10,8 +9,6 @@ const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Draft attachment records per workspace: { [sessionId]: Attachment[] }. */
|
||||
const DRAFT_ATTACHMENTS_KEY = "multica:chat:draft-attachments";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
|
||||
@@ -60,49 +57,6 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
|
||||
}
|
||||
}
|
||||
|
||||
function isAttachmentDraft(value: unknown): value is Attachment {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as { id?: unknown }).id === "string" &&
|
||||
typeof (value as { filename?: unknown }).filename === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function readDraftAttachments(storage: StorageAdapter, key: string): Record<string, Attachment[]> {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const out: Record<string, Attachment[]> = {};
|
||||
for (const [draftKey, value] of Object.entries(parsed)) {
|
||||
if (!Array.isArray(value)) continue;
|
||||
const attachments = value.filter(isAttachmentDraft);
|
||||
if (attachments.length > 0) out[draftKey] = attachments;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraftAttachments(
|
||||
storage: StorageAdapter,
|
||||
key: string,
|
||||
drafts: Record<string, Attachment[]>,
|
||||
) {
|
||||
const pruned: Record<string, Attachment[]> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v.length > 0) pruned[k] = v;
|
||||
}
|
||||
if (Object.keys(pruned).length === 0) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
storage.setItem(key, JSON.stringify(pruned));
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 380;
|
||||
@@ -129,8 +83,6 @@ export interface ChatState {
|
||||
selectedAgentId: string | null;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/** Attachment rows referenced by each input draft. */
|
||||
inputDraftAttachments: Record<string, Attachment[]>;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
@@ -141,9 +93,15 @@ export interface ChatState {
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
setInputDraftAttachments: (sessionId: string, attachments: Attachment[]) => void;
|
||||
addInputDraftAttachment: (sessionId: string, attachment: Attachment) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/**
|
||||
* Move a draft from one key to another, deleting the source. Used when a
|
||||
* chat session is lazily created: the `__new__:agent` draft is migrated
|
||||
* onto the real sessionId so it isn't stranded under the abandoned key
|
||||
* (which would resurface as a stale draft the next time a new chat opens
|
||||
* for that agent).
|
||||
*/
|
||||
migrateInputDraft: (from: string, to: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -172,7 +130,6 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
inputDraftAttachments: readDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY)),
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
@@ -208,40 +165,30 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setInputDraftAttachments: (sessionId, attachments) => {
|
||||
logger.debug("setInputDraftAttachments", { sessionId, count: attachments.length });
|
||||
const next = { ...get().inputDraftAttachments };
|
||||
if (attachments.length > 0) next[sessionId] = attachments;
|
||||
else delete next[sessionId];
|
||||
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
|
||||
set({ inputDraftAttachments: next });
|
||||
},
|
||||
addInputDraftAttachment: (sessionId, attachment) => {
|
||||
if (!attachment.id) return;
|
||||
const current = get().inputDraftAttachments;
|
||||
const existing = current[sessionId] ?? [];
|
||||
const nextForKey = existing.some((a) => a.id === attachment.id)
|
||||
? existing.map((a) => (a.id === attachment.id ? attachment : a))
|
||||
: [...existing, attachment];
|
||||
const next = { ...current, [sessionId]: nextForKey };
|
||||
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), next);
|
||||
set({ inputDraftAttachments: next });
|
||||
},
|
||||
clearInputDraft: (sessionId) => {
|
||||
const currentDrafts = get().inputDrafts;
|
||||
const currentAttachments = get().inputDraftAttachments;
|
||||
if (!(sessionId in currentDrafts) && !(sessionId in currentAttachments)) {
|
||||
const current = get().inputDrafts;
|
||||
if (!(sessionId in current)) {
|
||||
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
|
||||
return;
|
||||
}
|
||||
logger.info("clearInputDraft", { sessionId });
|
||||
const nextDrafts = { ...currentDrafts };
|
||||
const nextAttachments = { ...currentAttachments };
|
||||
delete nextDrafts[sessionId];
|
||||
delete nextAttachments[sessionId];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), nextDrafts);
|
||||
writeDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY), nextAttachments);
|
||||
set({ inputDrafts: nextDrafts, inputDraftAttachments: nextAttachments });
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
migrateInputDraft: (from, to) => {
|
||||
if (from === to) return;
|
||||
const current = get().inputDrafts;
|
||||
if (!(from in current)) {
|
||||
logger.debug("migrateInputDraft skipped (no source draft)", { from, to });
|
||||
return;
|
||||
}
|
||||
logger.info("migrateInputDraft", { from, to });
|
||||
const next = { ...current, [to]: current[from]! };
|
||||
delete next[from];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
@@ -266,20 +213,17 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
|
||||
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
|
||||
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
|
||||
const nextDraftAttachments = readDraftAttachments(storage, wsKey(DRAFT_ATTACHMENTS_KEY));
|
||||
logger.info("workspace rehydration", {
|
||||
prevSession: store.getState().activeSessionId,
|
||||
nextSession,
|
||||
prevAgent: store.getState().selectedAgentId,
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
draftAttachmentCount: Object.keys(nextDraftAttachments).length,
|
||||
});
|
||||
store.setState({
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
inputDraftAttachments: nextDraftAttachments,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("installFreezeWatchdog", () => {
|
||||
@@ -97,38 +96,4 @@ describe("installFreezeWatchdog", () => {
|
||||
|
||||
expect(() => installFreezeWatchdog()).not.toThrow();
|
||||
});
|
||||
|
||||
it("emits at most one client_unresponsive per 60s cooldown window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
// A sustained freeze arrives as several long-task entries back to back.
|
||||
fireLongTask(2500);
|
||||
fireLongTask(2500);
|
||||
fireLongTask(3000);
|
||||
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits again only after the cooldown window elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
fireLongTask(2500);
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Still inside the window → suppressed.
|
||||
vi.advanceTimersByTime(59_999);
|
||||
fireLongTask(2500);
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Window elapsed → emits again.
|
||||
vi.advanceTimersByTime(1);
|
||||
fireLongTask(2500);
|
||||
expect(captureEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,16 +24,6 @@ import { captureEvent } from "../analytics";
|
||||
// felt a real stall" without flooding on routine heavy renders.
|
||||
const FREEZE_THRESHOLD_MS = 2000;
|
||||
|
||||
// A single sustained freeze is delivered by the browser as several separate
|
||||
// long-task entries, so emitting per entry makes client_unresponsive volume
|
||||
// grow without bound with the freeze length (MUL-3331). A global cooldown caps
|
||||
// it to at most one event per window. Module-level (page-lifetime) state is the
|
||||
// right scope here — it matches the `installed` singleton and resets on a full
|
||||
// reload, which is rare and itself a distinct signal. No route bucketing: a
|
||||
// global window is the most direct cap on volume.
|
||||
const COOLDOWN_MS = 60_000;
|
||||
let lastEmitMs = 0;
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
@@ -51,11 +41,6 @@ export function installFreezeWatchdog(): void {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
|
||||
// Cooldown is checked only against qualifying freezes, so sub-threshold
|
||||
// long tasks neither emit nor reset the window.
|
||||
const now = Date.now();
|
||||
if (now - lastEmitMs < COOLDOWN_MS) continue;
|
||||
lastEmitMs = now;
|
||||
captureEvent("client_unresponsive", {
|
||||
source: "longtask",
|
||||
duration_ms: Math.round(entry.duration),
|
||||
|
||||
@@ -79,13 +79,6 @@ export interface SendChatMessageResponse {
|
||||
* timer "snaps backwards" later when WS events update the cache.
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Attachment ids the server actually bound to this message. The client
|
||||
* diffs these against the ids it requested to warn when an attachment
|
||||
* silently failed to bind — no extra fetch needed. Optional for forward
|
||||
* compat with servers that predate the field.
|
||||
*/
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CancelledChatMessage {
|
||||
@@ -93,11 +86,6 @@ export interface CancelledChatMessage {
|
||||
message_id: string;
|
||||
content: string;
|
||||
restore_to_input: boolean;
|
||||
/**
|
||||
* Attachments detached from the deleted message so a restored draft can
|
||||
* re-bind them on re-send. Absent on servers that predate the field.
|
||||
*/
|
||||
attachments?: import("./attachment").Attachment[];
|
||||
}
|
||||
|
||||
export interface CancelTaskResponse extends AgentTask {
|
||||
|
||||
@@ -39,9 +39,6 @@ const dropHandlers = vi.hoisted(() => ({
|
||||
const editorProps = vi.hoisted(() => ({
|
||||
last: null as null | Record<string, unknown>,
|
||||
}));
|
||||
// Records imperative editor calls so tests can assert whether a commit
|
||||
// scrubbed the editor (clearEditor) or left it intact (fire-and-forget).
|
||||
const editorState = vi.hoisted(() => ({ cleared: 0, blurred: 0 }));
|
||||
|
||||
vi.mock("../../editor", () => ({
|
||||
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
|
||||
@@ -72,12 +69,9 @@ vi.mock("../../editor", () => ({
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => {
|
||||
editorState.cleared += 1;
|
||||
valueRef.current = "";
|
||||
},
|
||||
blur: () => {
|
||||
editorState.blurred += 1;
|
||||
},
|
||||
blur: () => {},
|
||||
focus: () => {},
|
||||
uploadFile: async (file: File) => {
|
||||
uploadingRef.current += 1;
|
||||
@@ -122,10 +116,7 @@ vi.mock("@multica/core/chat", () => {
|
||||
activeSessionId: null as string | null,
|
||||
selectedAgentId: "agent-1",
|
||||
inputDrafts: {} as Record<string, string>,
|
||||
inputDraftAttachments: {} as Record<string, UploadResult[]>,
|
||||
setInputDraft: vi.fn(),
|
||||
setInputDraftAttachments: vi.fn(),
|
||||
addInputDraftAttachment: vi.fn(),
|
||||
clearInputDraft: vi.fn(),
|
||||
};
|
||||
return {
|
||||
@@ -142,49 +133,21 @@ vi.mock("@multica/core/chat", () => {
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
|
||||
type ChatInputOnSend = React.ComponentProps<typeof ChatInput>["onSend"];
|
||||
type ChatInputCommit = Parameters<ChatInputOnSend>[2];
|
||||
|
||||
beforeEach(() => {
|
||||
dropHandlers.onDrop = null;
|
||||
editorProps.last = null;
|
||||
editorState.cleared = 0;
|
||||
editorState.blurred = 0;
|
||||
const state = useChatStore.getState() as unknown as {
|
||||
activeSessionId: string | null;
|
||||
selectedAgentId: string;
|
||||
inputDrafts: Record<string, string>;
|
||||
setInputDraft: ReturnType<typeof vi.fn>;
|
||||
clearInputDraft: ReturnType<typeof vi.fn>;
|
||||
inputDraftAttachments: Record<string, UploadResult[]>;
|
||||
setInputDraftAttachments: ReturnType<typeof vi.fn>;
|
||||
addInputDraftAttachment: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
state.activeSessionId = null;
|
||||
state.selectedAgentId = "agent-1";
|
||||
state.inputDrafts = {};
|
||||
state.inputDraftAttachments = {};
|
||||
state.setInputDraft.mockClear();
|
||||
state.setInputDraft.mockImplementation((key: string, value: string) => {
|
||||
state.inputDrafts[key] = value;
|
||||
});
|
||||
state.setInputDraftAttachments.mockClear();
|
||||
state.setInputDraftAttachments.mockImplementation((key: string, attachments: UploadResult[]) => {
|
||||
if (attachments.length > 0) state.inputDraftAttachments[key] = attachments;
|
||||
else delete state.inputDraftAttachments[key];
|
||||
});
|
||||
state.addInputDraftAttachment.mockClear();
|
||||
state.addInputDraftAttachment.mockImplementation((key: string, attachment: UploadResult) => {
|
||||
const existing = state.inputDraftAttachments[key] ?? [];
|
||||
state.inputDraftAttachments[key] = existing.some((a) => a.id === attachment.id)
|
||||
? existing.map((a) => (a.id === attachment.id ? attachment : a))
|
||||
: [...existing, attachment];
|
||||
});
|
||||
state.clearInputDraft.mockClear();
|
||||
state.clearInputDraft.mockImplementation((key: string) => {
|
||||
delete state.inputDrafts[key];
|
||||
delete state.inputDraftAttachments[key];
|
||||
});
|
||||
});
|
||||
|
||||
function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}) {
|
||||
@@ -260,10 +223,6 @@ describe("ChatInput attachment wiring", () => {
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
const [, ids] = onSend.mock.calls[0]!;
|
||||
expect(ids).toEqual(["att-42"]);
|
||||
expect(useChatStore.getState().addInputDraftAttachment).toHaveBeenCalledWith(
|
||||
"__draft_new__:agent-1",
|
||||
expect.objectContaining({ id: "att-42" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds attachment_ids when the upload's markdownLink differs from its link (MUL-3130 regression)", async () => {
|
||||
@@ -420,12 +379,12 @@ describe("ChatInput async send", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the draft while send is pending until the owner commits the handoff", async () => {
|
||||
it("keeps the draft while send is pending and clears after acceptance", async () => {
|
||||
let resolveSend: (accepted: boolean) => void;
|
||||
const sendPromise = new Promise<boolean>((res) => {
|
||||
resolveSend = res;
|
||||
});
|
||||
const onSend = vi.fn<ChatInputOnSend>(() => sendPromise);
|
||||
const onSend = vi.fn(() => sendPromise);
|
||||
renderInput({ onSend });
|
||||
|
||||
fireEvent.change(screen.getByTestId("editor"), { target: { value: "slow network" } });
|
||||
@@ -439,29 +398,18 @@ describe("ChatInput async send", () => {
|
||||
|
||||
fireEvent.click(sendButton!);
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
"slow network",
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
[],
|
||||
);
|
||||
expect(onSend).toHaveBeenCalledWith("slow network", undefined);
|
||||
expect(useChatStore.getState().clearInputDraft).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(sendButton!).toBeDisabled());
|
||||
|
||||
const commitInput = onSend.mock.calls[0]![2] as ChatInputCommit;
|
||||
act(() => {
|
||||
commitInput({ extraDraftKeys: ["session-1"] });
|
||||
});
|
||||
|
||||
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
|
||||
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("session-1");
|
||||
|
||||
await act(async () => {
|
||||
resolveSend!(true);
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() => {
|
||||
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the draft when send is rejected by the owner", async () => {
|
||||
@@ -482,148 +430,7 @@ describe("ChatInput async send", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("retry me", undefined, expect.any(Function), []);
|
||||
expect(onSend).toHaveBeenCalledWith("retry me", undefined);
|
||||
expect(useChatStore.getState().clearInputDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends attachment ids restored from persisted draft attachments", async () => {
|
||||
const state = useChatStore.getState() as unknown as {
|
||||
inputDrafts: Record<string, string>;
|
||||
inputDraftAttachments: Record<string, UploadResult[]>;
|
||||
};
|
||||
const attachment = makeUpload({
|
||||
id: "att-persisted",
|
||||
link: "/api/attachments/att-persisted/download",
|
||||
filename: "persisted.png",
|
||||
});
|
||||
state.inputDrafts["__draft_new__:agent-1"] = "see ";
|
||||
state.inputDraftAttachments["__draft_new__:agent-1"] = [attachment];
|
||||
|
||||
const onSend = vi.fn<ChatInputOnSend>((_content, _ids, commitInput) => {
|
||||
commitInput();
|
||||
return true;
|
||||
});
|
||||
renderInput({ onSend });
|
||||
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(sendButton!);
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
"see ",
|
||||
["att-persisted"],
|
||||
expect.any(Function),
|
||||
[attachment],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A failed fire-and-forget send must restore into the session it was sent
|
||||
// FROM, never into whatever session the user navigated to in the meantime.
|
||||
describe("ChatInput session-aware restore", () => {
|
||||
function element(props: Partial<React.ComponentProps<typeof ChatInput>>) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={vi.fn()} onUploadFile={vi.fn()} agentName="Multica" {...props} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it("holds a session-scoped restore until the user returns to the source session", async () => {
|
||||
const state = useChatStore.getState() as unknown as {
|
||||
activeSessionId: string | null;
|
||||
setInputDraft: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
// User is viewing session-b; the failed send belongs to session-a.
|
||||
state.activeSessionId = "session-b";
|
||||
const onRestoreDraftConsumed = vi.fn();
|
||||
const props = {
|
||||
restoreDraftRequest: { id: "r1", content: "from A", sessionId: "session-a" },
|
||||
onRestoreDraftConsumed,
|
||||
};
|
||||
const { rerender } = render(element(props));
|
||||
|
||||
// Pending — must NOT dump A's content into session-b.
|
||||
expect(onRestoreDraftConsumed).not.toHaveBeenCalled();
|
||||
expect(state.setInputDraft).not.toHaveBeenCalledWith("session-b", "from A");
|
||||
|
||||
// User navigates back to the source session → the pending restore fires.
|
||||
state.activeSessionId = "session-a";
|
||||
rerender(element(props));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.setInputDraft).toHaveBeenCalledWith("session-a", "from A");
|
||||
expect(onRestoreDraftConsumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes a session-scoped restore when already on that session", async () => {
|
||||
const state = useChatStore.getState() as unknown as {
|
||||
activeSessionId: string | null;
|
||||
setInputDraft: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
state.activeSessionId = "session-a";
|
||||
const onRestoreDraftConsumed = vi.fn();
|
||||
render(
|
||||
element({
|
||||
restoreDraftRequest: { id: "r2", content: "hi A", sessionId: "session-a" },
|
||||
onRestoreDraftConsumed,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.setInputDraft).toHaveBeenCalledWith("session-a", "hi A");
|
||||
expect(onRestoreDraftConsumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// commitInput is the handoff: the owner (ChatWindow) decides WHEN and HOW to
|
||||
// clear the input. clearEditor:false is the fire-and-forget case — the user
|
||||
// navigated away, so the shared editor now shows another session's draft and
|
||||
// must not be scrubbed, but the SENT draft's data is still cleared.
|
||||
describe("ChatInput commit handoff", () => {
|
||||
async function typeAndSend(onSend: ChatInputOnSend) {
|
||||
renderInput({ onSend });
|
||||
fireEvent.change(screen.getByTestId("editor"), { target: { value: "msg" } });
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(sendButton!);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled());
|
||||
}
|
||||
|
||||
it("scrubs the editor and clears the draft on a normal commit", async () => {
|
||||
const onSend = vi.fn<ChatInputOnSend>((_content, _ids, commitInput) => {
|
||||
commitInput();
|
||||
return true;
|
||||
});
|
||||
await typeAndSend(onSend);
|
||||
|
||||
expect(editorState.cleared).toBeGreaterThan(0);
|
||||
expect(editorState.blurred).toBeGreaterThan(0);
|
||||
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
|
||||
});
|
||||
|
||||
it("leaves the editor intact on a fire-and-forget commit but still clears the sent draft", async () => {
|
||||
const onSend = vi.fn<ChatInputOnSend>((_content, _ids, commitInput) => {
|
||||
commitInput({ clearEditor: false });
|
||||
return true;
|
||||
});
|
||||
await typeAndSend(onSend);
|
||||
|
||||
// Editor untouched — it now shows the session the user navigated to.
|
||||
expect(editorState.cleared).toBe(0);
|
||||
expect(editorState.blurred).toBe(0);
|
||||
// …but the sent session's persisted draft is cleared regardless.
|
||||
expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,50 +16,13 @@ import { createLogger } from "@multica/core/logger";
|
||||
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import type { MentionItem } from "../../editor/extensions/mention-suggestion";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
const EMPTY_ATTACHMENTS: Attachment[] = [];
|
||||
|
||||
function attachmentReferenceUrls(attachment: Attachment): string[] {
|
||||
const withUploadFields = attachment as Attachment & {
|
||||
markdownLink?: string;
|
||||
link?: string;
|
||||
};
|
||||
return [
|
||||
withUploadFields.markdownLink,
|
||||
attachment.markdown_url,
|
||||
attachment.download_url,
|
||||
attachment.url,
|
||||
withUploadFields.link,
|
||||
attachment.id ? `/api/attachments/${attachment.id}/download` : "",
|
||||
].filter((url): url is string => !!url);
|
||||
}
|
||||
|
||||
function isAttachmentReferenced(content: string, attachment: Attachment): boolean {
|
||||
return attachmentReferenceUrls(attachment).some((url) => content.includes(url));
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (
|
||||
content: string,
|
||||
attachmentIds: string[] | undefined,
|
||||
commitInput: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
|
||||
draftAttachments: Attachment[],
|
||||
) => void | boolean | Promise<void | boolean>;
|
||||
restoreDraftRequest?: {
|
||||
id: string;
|
||||
content: string;
|
||||
attachments?: Attachment[];
|
||||
/**
|
||||
* Draft slot this restore targets. When set, the restore only fires while
|
||||
* the user is viewing that session — a fire-and-forget send that later
|
||||
* fails restores into the session it was sent from, not whatever the user
|
||||
* navigated to. Omit to restore into the current draft (legacy behavior).
|
||||
*/
|
||||
sessionId?: string;
|
||||
} | null;
|
||||
onSend: (content: string, attachmentIds?: string[]) => void | boolean | Promise<void | boolean>;
|
||||
restoreDraftRequest?: { id: string; content: string } | null;
|
||||
onRestoreDraftConsumed?: () => void;
|
||||
/** Receives a File and returns the attachment row (with id + CDN link).
|
||||
* The wrapper owner (ChatWindow) lazy-creates a chat_session if needed
|
||||
@@ -125,12 +88,7 @@ export function ChatInput({
|
||||
const draftKey = activeSessionId ?? newSessionDraftKey(selectedAgentId);
|
||||
// Select a primitive — empty-string fallback keeps referential stability.
|
||||
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
|
||||
const draftAttachments = useChatStore(
|
||||
(s) => s.inputDraftAttachments[draftKey] ?? EMPTY_ATTACHMENTS,
|
||||
);
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
const setInputDraftAttachments = useChatStore((s) => s.setInputDraftAttachments);
|
||||
const addInputDraftAttachment = useChatStore((s) => s.addInputDraftAttachment);
|
||||
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -139,7 +97,6 @@ export function ChatInput({
|
||||
content: string;
|
||||
draftKey: string;
|
||||
} | null>(null);
|
||||
const consumedRestoreIdRef = useRef<string | null>(null);
|
||||
const activeRestore = editorRestore?.draftKey === draftKey ? editorRestore : null;
|
||||
const editorKey = `${selectedAgentId ?? "no-agent"}:${activeRestore?.id ?? "base"}`;
|
||||
// Number of in-flight uploads. We track this explicitly (rather than
|
||||
@@ -165,20 +122,7 @@ export function ChatInput({
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoreDraftRequest) {
|
||||
consumedRestoreIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (consumedRestoreIdRef.current === restoreDraftRequest.id) return;
|
||||
// Session-scoped restore: if this draft belongs to a specific session,
|
||||
// wait until the user is actually viewing it. A fire-and-forget send that
|
||||
// failed after the user navigated away must not dump its content into the
|
||||
// session they're now looking at — the request stays pending until they
|
||||
// return to the source session (draftKey then matches).
|
||||
if (restoreDraftRequest.sessionId && restoreDraftRequest.sessionId !== draftKey) {
|
||||
return;
|
||||
}
|
||||
consumedRestoreIdRef.current = restoreDraftRequest.id;
|
||||
if (!restoreDraftRequest) return;
|
||||
if (inputDraft.trim()) {
|
||||
logger.info("input.restore skipped: draft already has content", {
|
||||
draftKey,
|
||||
@@ -188,7 +132,6 @@ export function ChatInput({
|
||||
return;
|
||||
}
|
||||
setInputDraft(draftKey, restoreDraftRequest.content);
|
||||
setInputDraftAttachments(draftKey, restoreDraftRequest.attachments ?? []);
|
||||
setIsEmpty(!restoreDraftRequest.content.trim());
|
||||
setEditorRestore({
|
||||
id: restoreDraftRequest.id,
|
||||
@@ -196,14 +139,7 @@ export function ChatInput({
|
||||
draftKey,
|
||||
});
|
||||
onRestoreDraftConsumed?.();
|
||||
}, [
|
||||
draftKey,
|
||||
inputDraft,
|
||||
onRestoreDraftConsumed,
|
||||
restoreDraftRequest,
|
||||
setInputDraft,
|
||||
setInputDraftAttachments,
|
||||
]);
|
||||
}, [draftKey, inputDraft, onRestoreDraftConsumed, restoreDraftRequest, setInputDraft]);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File): Promise<UploadResult | null> => {
|
||||
@@ -214,14 +150,13 @@ export function ChatInput({
|
||||
if (result) {
|
||||
const persistedURL = result.markdownLink || result.link;
|
||||
uploadMapRef.current.set(persistedURL, result.id);
|
||||
if (result.id) addInputDraftAttachment(draftKey, result);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
setPendingUploads((n) => Math.max(0, n - 1));
|
||||
}
|
||||
},
|
||||
[addInputDraftAttachment, draftKey, onUploadFile],
|
||||
[onUploadFile],
|
||||
);
|
||||
|
||||
// Drop zone wraps the rounded card so a drop anywhere on the input
|
||||
@@ -260,69 +195,38 @@ export function ChatInput({
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
for (const attachment of draftAttachments) {
|
||||
if (isAttachmentReferenced(content, attachment)) activeIds.push(attachment.id);
|
||||
}
|
||||
const uniqueActiveIds = Array.from(new Set(activeIds));
|
||||
// Capture draft key BEFORE onSend — creating a new session mutates
|
||||
// activeSessionId synchronously, so reading it after onSend would point
|
||||
// at the new session and leave the old draft orphaned.
|
||||
const keyAtSend = draftKey;
|
||||
let committed = false;
|
||||
const commitInput = (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => {
|
||||
if (committed) return;
|
||||
committed = true;
|
||||
// `clearEditor === false` means the owner sent fire-and-forget while the
|
||||
// user had already navigated to another session. The editor instance is
|
||||
// shared across sessions, so it now shows (and the user may be typing
|
||||
// into) a DIFFERENT draft — clearing it or blurring would wipe that
|
||||
// visible input. Only scrub the editor when the user is still on the
|
||||
// session they sent from.
|
||||
if (options?.clearEditor !== false) {
|
||||
editorRef.current?.clearContent();
|
||||
// Drop focus so the caret doesn't keep blinking under the StatusPill /
|
||||
// streaming reply that's about to take over the user's attention. The
|
||||
// input is also `disabled` once isRunning flips, and a focused-but-
|
||||
// disabled editor reads as a stale cursor. We deliberately don't auto-
|
||||
// refocus on completion — that would interrupt the user if they're
|
||||
// selecting text from the assistant reply; one click to refocus is
|
||||
// a fair price for not stealing focus mid-action.
|
||||
editorRef.current?.blur();
|
||||
setIsEmpty(true);
|
||||
}
|
||||
// The sent draft's data is cleared regardless — the message is on its
|
||||
// way, so its persisted draft must not resurface.
|
||||
clearInputDraft(keyAtSend);
|
||||
for (const key of options?.extraDraftKeys ?? []) {
|
||||
if (key !== keyAtSend) clearInputDraft(key);
|
||||
}
|
||||
uploadMapRef.current.clear();
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
logger.info("input.send", {
|
||||
contentLength: content.length,
|
||||
draftKey: keyAtSend,
|
||||
attachmentCount: uniqueActiveIds.length,
|
||||
attachmentCount: activeIds.length,
|
||||
});
|
||||
setIsSubmitting(true);
|
||||
let accepted: void | boolean;
|
||||
try {
|
||||
accepted = await onSend(
|
||||
content,
|
||||
uniqueActiveIds.length > 0 ? uniqueActiveIds : undefined,
|
||||
commitInput,
|
||||
draftAttachments.filter((attachment) => uniqueActiveIds.includes(attachment.id)),
|
||||
);
|
||||
accepted = await onSend(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
} catch (err) {
|
||||
logger.warn("input.send failed", err);
|
||||
if (!committed) setIsSubmitting(false);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (accepted === false) {
|
||||
if (!committed) setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!committed) commitInput();
|
||||
setIsSubmitting(false);
|
||||
if (accepted === false) return;
|
||||
editorRef.current?.clearContent();
|
||||
// Drop focus so the caret doesn't keep blinking under the StatusPill /
|
||||
// streaming reply that's about to take over the user's attention. The
|
||||
// input is also `disabled` once isRunning flips, and a focused-but-
|
||||
// disabled editor reads as a stale cursor. We deliberately don't auto-
|
||||
// refocus on completion — that would interrupt the user if they're
|
||||
// selecting text from the assistant reply; one click to refocus is
|
||||
// a fair price for not stealing focus mid-action.
|
||||
editorRef.current?.blur();
|
||||
clearInputDraft(keyAtSend);
|
||||
uploadMapRef.current.clear();
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
const placeholder = noAgent
|
||||
@@ -371,18 +275,9 @@ export function ChatInput({
|
||||
onUpdate={(md) => {
|
||||
setIsEmpty(!md.trim());
|
||||
setInputDraft(draftKey, md);
|
||||
if (draftAttachments.length > 0) {
|
||||
const referenced = draftAttachments.filter((attachment) =>
|
||||
isAttachmentReferenced(md, attachment),
|
||||
);
|
||||
if (referenced.length !== draftAttachments.length) {
|
||||
setInputDraftAttachments(draftKey, referenced);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSend}
|
||||
onUploadFile={uploadEnabled ? handleUpload : undefined}
|
||||
attachments={draftAttachments}
|
||||
debounceMs={100}
|
||||
mentionMode={contextItems ? "context" : "default"}
|
||||
mentionContextItems={contextItems}
|
||||
@@ -412,7 +307,6 @@ export function ChatInput({
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || isSubmitting || !!disabled || !!noAgent || pendingUploads > 0}
|
||||
loading={isSubmitting}
|
||||
running={isRunning}
|
||||
onStop={onStop}
|
||||
tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
|
||||
|
||||
@@ -44,20 +44,39 @@ import {
|
||||
useMarkChatSessionRead,
|
||||
useUpdateChatSession,
|
||||
} from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { useChatStore, newSessionDraftKey } from "@multica/core/chat";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
import { useChatContextItems } from "./use-chat-context-items";
|
||||
import { useChatResize } from "./use-chat-resize";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { Agent, Attachment, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
|
||||
import type { Agent, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const uiLogger = createLogger("chat.ui");
|
||||
const apiLogger = createLogger("chat.api");
|
||||
const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000;
|
||||
|
||||
function seedChatMessagesPageCache(
|
||||
qc: ReturnType<typeof useQueryClient>,
|
||||
sessionId: string,
|
||||
messages: ChatMessage[],
|
||||
) {
|
||||
qc.setQueryData<InfiniteData<ChatMessagesPage>>(
|
||||
chatKeys.messagesPage(sessionId),
|
||||
(old) => old ?? {
|
||||
pages: [{
|
||||
messages,
|
||||
limit: 50,
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
}],
|
||||
pageParams: [null],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function appendChatMessageToLatestPageCache(
|
||||
qc: ReturnType<typeof useQueryClient>,
|
||||
sessionId: string,
|
||||
@@ -167,6 +186,7 @@ export function ChatWindow() {
|
||||
const setOpen = useChatStore((s) => s.setOpen);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||||
const migrateInputDraft = useChatStore((s) => s.migrateInputDraft);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
@@ -210,8 +230,6 @@ export function ChatWindow() {
|
||||
const [restoreDraftRequest, setRestoreDraftRequest] = useState<{
|
||||
id: string;
|
||||
content: string;
|
||||
attachments?: Attachment[];
|
||||
sessionId?: string;
|
||||
} | null>(null);
|
||||
const handleRestoreDraftConsumed = useCallback(() => {
|
||||
setRestoreDraftRequest(null);
|
||||
@@ -353,14 +371,40 @@ export function ChatWindow() {
|
||||
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!activeAgent) return null;
|
||||
// Uploads are workspace-scoped drafts. Sending the message is the point
|
||||
// where we create a chat session (if needed) and bind attachment_ids to
|
||||
// the persisted chat_message row. This keeps a paste/drop from creating
|
||||
// an empty chat session the user never sends.
|
||||
return uploadWithToast(file);
|
||||
// An upload in a brand-new chat lazily creates the session, flipping the
|
||||
// draft key from `__new__:agent` to the session id mid-upload. The
|
||||
// in-progress (empty-href) file-card markdown the editor already wrote
|
||||
// into the `__new__:agent` draft would otherwise be stranded there and
|
||||
// resurface as a stale `!file[name]()` the next time a new chat opens for
|
||||
// this agent. Migrate that draft onto the session id so it travels with
|
||||
// the session and the `__new__:agent` slot is cleared.
|
||||
const wasNewSession = !activeSessionId;
|
||||
const sessionId = await ensureSession("");
|
||||
if (!sessionId) return null;
|
||||
if (wasNewSession) {
|
||||
migrateInputDraft(newSessionDraftKey(selectedAgentId), sessionId);
|
||||
}
|
||||
// Prime the messages cache as empty before flipping activeSessionId so
|
||||
// ChatMessageList mounts directly (no Skeleton frame). Skip the write
|
||||
// when an entry already exists — a concurrent handleSend may have
|
||||
// seeded an optimistic message we must not clobber.
|
||||
seedChatMessagesPageCache(qc, sessionId, []);
|
||||
qc.setQueryData<ChatMessage[]>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => old ?? [],
|
||||
);
|
||||
setActiveSession(sessionId);
|
||||
return uploadWithToast(file, { chatSessionId: sessionId });
|
||||
},
|
||||
[activeAgent, uploadWithToast],
|
||||
[
|
||||
activeSessionId,
|
||||
ensureSession,
|
||||
migrateInputDraft,
|
||||
selectedAgentId,
|
||||
uploadWithToast,
|
||||
qc,
|
||||
setActiveSession,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelChatTask = useCallback(
|
||||
@@ -385,8 +429,6 @@ export function ChatWindow() {
|
||||
setRestoreDraftRequest({
|
||||
id: restored.message_id,
|
||||
content: restored.content,
|
||||
attachments: restored.attachments,
|
||||
sessionId: restored.chat_session_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -413,12 +455,7 @@ export function ChatWindow() {
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
attachmentIds?: string[],
|
||||
commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
|
||||
draftAttachments: Attachment[] = [],
|
||||
): Promise<boolean> => {
|
||||
async (content: string, attachmentIds?: string[]): Promise<boolean> => {
|
||||
if (!activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||||
return false;
|
||||
@@ -462,7 +499,6 @@ export function ChatWindow() {
|
||||
content: finalContent,
|
||||
task_id: null,
|
||||
created_at: sentAt,
|
||||
attachments: draftAttachments,
|
||||
};
|
||||
// Seed cache BEFORE flipping activeSessionId. If we set the active
|
||||
// session first, useQuery's first subscription to the new key sees no
|
||||
@@ -485,22 +521,9 @@ export function ChatWindow() {
|
||||
status: "queued",
|
||||
created_at: sentAt,
|
||||
});
|
||||
// Cache primed → safe to publish the new active session. But only steal
|
||||
// focus if the user is STILL on the compose target they sent from — if
|
||||
// they navigated away mid-send, this is fire-and-forget: the reply
|
||||
// surfaces via the unread dot on the sent session, we don't yank the
|
||||
// view back. Compare the live store against the closure-captured target.
|
||||
// For a brand-new chat (activeSessionId === null) the target is keyed by
|
||||
// the selected agent, so switching agents to start a different new chat
|
||||
// must also count as "navigated away" even though both sides are null.
|
||||
const live = useChatStore.getState();
|
||||
const stillOnSourceSession =
|
||||
live.activeSessionId === activeSessionId &&
|
||||
(activeSessionId !== null || live.selectedAgentId === selectedAgentId);
|
||||
if (stillOnSourceSession) {
|
||||
setActiveSession(sessionId);
|
||||
}
|
||||
commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession });
|
||||
// Cache primed → safe to publish the new active session. Idempotent
|
||||
// when the session was already active (existing-conversation send).
|
||||
setActiveSession(sessionId);
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
let result;
|
||||
@@ -511,15 +534,6 @@ export function ChatWindow() {
|
||||
stopRequestedBeforeTaskRef.current = false;
|
||||
removeChatMessageFromCaches(qc, sessionId, optimistic.id);
|
||||
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
|
||||
setRestoreDraftRequest({
|
||||
id: `send-failed-${optimistic.id}`,
|
||||
content: finalContent,
|
||||
attachments: draftAttachments,
|
||||
// Restore into the session this was sent from. If the user
|
||||
// navigated away (fire-and-forget) the request waits until they
|
||||
// return rather than dumping content into another session.
|
||||
sessionId,
|
||||
});
|
||||
toast.error(t(($) => $.input.send_failed_toast));
|
||||
return false;
|
||||
}
|
||||
@@ -545,29 +559,12 @@ export function ChatWindow() {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// The server reports which attachment ids it actually bound. Diff
|
||||
// against what we requested so a silent bind failure surfaces to the
|
||||
// user — no extra fetch. Skip the check on servers that predate the
|
||||
// field (attachment_ids undefined) rather than false-alarm.
|
||||
if (attachmentIds && attachmentIds.length > 0 && result.attachment_ids) {
|
||||
const boundIds = new Set(result.attachment_ids);
|
||||
const missing = attachmentIds.filter((id) => !boundIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
apiLogger.warn("sendChatMessage.attachments missing after send", {
|
||||
sessionId,
|
||||
messageId: result.message_id,
|
||||
missing,
|
||||
});
|
||||
toast.error(t(($) => $.input.attachment_bind_failed_toast));
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
|
||||
return true;
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
selectedAgentId,
|
||||
activeAgent,
|
||||
ensureSession,
|
||||
cancelChatTask,
|
||||
|
||||
@@ -103,7 +103,7 @@ interface CommentCardProps {
|
||||
* `CommentRow` has to rerun the rule per row.
|
||||
*/
|
||||
canModerate?: boolean;
|
||||
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
|
||||
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
|
||||
@@ -88,16 +88,16 @@ function renderWithProviders(ui: ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(true)) {
|
||||
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(undefined)) {
|
||||
const view = renderWithProviders(<CommentInput issueId="issue-1" onSubmit={onSubmit} />);
|
||||
return { ...view, onSubmit };
|
||||
}
|
||||
|
||||
function renderReplyInput({
|
||||
onSubmit = vi.fn().mockResolvedValue(true),
|
||||
onSubmit = vi.fn().mockResolvedValue(undefined),
|
||||
size = "sm",
|
||||
}: {
|
||||
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
|
||||
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
} = {}) {
|
||||
const view = renderWithProviders(
|
||||
@@ -184,42 +184,4 @@ describe("comment composers", () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith("thread reply", undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("locks the editor while the send is in flight, then clears on success", async () => {
|
||||
let resolveSubmit: (ok: boolean) => void = () => {};
|
||||
const onSubmit = vi.fn(
|
||||
() => new Promise<boolean>((resolve) => { resolveSubmit = resolve; }),
|
||||
);
|
||||
const { container } = renderCommentInput(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByTestId("editor"), { target: { value: "sending" } });
|
||||
fireEvent.click(getSubmitButton(container));
|
||||
|
||||
// In flight: text kept, editor wrapper locked (aria-busy), not cleared yet.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("editor").closest("[aria-busy]")).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true",
|
||||
),
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalledWith("sending", undefined, undefined);
|
||||
|
||||
resolveSubmit(true);
|
||||
|
||||
// Success: the composer clears (now empty → submit disabled, lock released).
|
||||
await waitFor(() => expect(getSubmitButton(container)).toBeDisabled());
|
||||
expect(screen.getByTestId("editor").closest("[aria-busy]")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the draft when the send fails (no optimistic clear)", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(false);
|
||||
const { container } = renderCommentInput(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByTestId("editor"), { target: { value: "will fail" } });
|
||||
fireEvent.click(getSubmitButton(container));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
// Failed send must NOT clear — the box still has content, submit stays live.
|
||||
await waitFor(() => expect(getSubmitButton(container)).not.toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
@@ -17,10 +16,7 @@ import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview";
|
||||
|
||||
interface CommentInputProps {
|
||||
issueId: string;
|
||||
/** Resolves true on success, false on failure. The composer keeps the text
|
||||
* (editor locked + button spinning) until this settles, then clears only on
|
||||
* success — a failed send must not silently discard the user's draft. */
|
||||
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
|
||||
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
@@ -109,26 +105,19 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const suppressAgentIds = triggerPreview.agents
|
||||
.filter((agent) => suppressedAgentIds.has(agent.id))
|
||||
.map((agent) => agent.id);
|
||||
// Pessimistic submit: keep the text in place (the editor is locked and the
|
||||
// button spins via `submitting`) until the server actually accepts it, then
|
||||
// clear. Clearing only on success means a slow send no longer looks like
|
||||
// "comment posted but the box is still full", and a failed send keeps the
|
||||
// draft instead of silently dropping it.
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ok = await onSubmit(
|
||||
await onSubmit(
|
||||
content,
|
||||
activeIds.length > 0 ? activeIds : undefined,
|
||||
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
|
||||
);
|
||||
if (ok) {
|
||||
editorRef.current?.clearContent();
|
||||
setContent("");
|
||||
setIsEmpty(true);
|
||||
setSuppressedAgentIds(new Set());
|
||||
setPendingAttachments([]);
|
||||
clearDraft(draftKey);
|
||||
}
|
||||
editorRef.current?.clearContent();
|
||||
setContent("");
|
||||
setIsEmpty(true);
|
||||
setSuppressedAgentIds(new Set());
|
||||
setPendingAttachments([]);
|
||||
clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -139,17 +128,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
{...dropZoneProps}
|
||||
className="relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
|
||||
>
|
||||
{/* Lock the editor while the send is in flight. ContentEditor can't
|
||||
toggle Tiptap's `editable` post-mount (see its docstring), so the
|
||||
documented way to make it non-interactive is a pointer-events-none +
|
||||
dimmed wrapper. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto px-3 py-2",
|
||||
submitting && "pointer-events-none opacity-60",
|
||||
)}
|
||||
aria-busy={submitting || undefined}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={initialDraft}
|
||||
|
||||
@@ -844,26 +844,6 @@ describe("IssueDetail (shared)", () => {
|
||||
expect(screen.getByText(/changed priority/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders activity rows with unknown status values without crashing", async () => {
|
||||
mockApiObj.listTimeline.mockResolvedValue([
|
||||
{
|
||||
type: "activity",
|
||||
id: "act-unknown-status",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
action: "status_changed",
|
||||
details: { from: "todo", to: "mystery_status" },
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
},
|
||||
] as TimelineEntry[]);
|
||||
|
||||
renderIssueDetail();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/from Todo to mystery_status/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates the trailing activity block to the most recent 8 entries with a show-more toggle", async () => {
|
||||
// 10 activities, all in the trailing block (no comment after them, so it's
|
||||
// the trailing block by definition). Alternating action types so the
|
||||
|
||||
@@ -6,14 +6,14 @@ export function PriorityIcon({
|
||||
className = "",
|
||||
inheritColor = false,
|
||||
}: {
|
||||
priority: IssuePriority | string;
|
||||
priority: IssuePriority;
|
||||
className?: string;
|
||||
inheritColor?: boolean;
|
||||
}) {
|
||||
const cfg = priority in PRIORITY_CONFIG ? PRIORITY_CONFIG[priority as IssuePriority] : null;
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
|
||||
// "none" — simple horizontal dashes
|
||||
if (!cfg || cfg.bars === 0) {
|
||||
if (cfg.bars === 0) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
|
||||
@@ -26,9 +26,7 @@ interface ReplyInputProps {
|
||||
placeholder?: string;
|
||||
avatarType: string;
|
||||
avatarId: string;
|
||||
/** Resolves true on success, false on failure — the reply box keeps its text
|
||||
* (locked + spinning) until then, clearing only on success. */
|
||||
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
|
||||
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
/** When set, hydrates/persists the in-progress reply via the draft store.
|
||||
* Required for replies inside virtualized timeline threads, where the
|
||||
@@ -130,23 +128,19 @@ function ReplyInput({
|
||||
const suppressAgentIds = triggerPreview.agents
|
||||
.filter((agent) => suppressedAgentIds.has(agent.id))
|
||||
.map((agent) => agent.id);
|
||||
// Pessimistic submit (see CommentInput): keep the text, lock + spin, clear
|
||||
// only once the server accepts it.
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ok = await onSubmit(
|
||||
await onSubmit(
|
||||
content,
|
||||
activeIds.length > 0 ? activeIds : undefined,
|
||||
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
|
||||
);
|
||||
if (ok) {
|
||||
editorRef.current?.clearContent();
|
||||
setContent("");
|
||||
setIsEmpty(true);
|
||||
setSuppressedAgentIds(new Set());
|
||||
setPendingAttachments([]);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
}
|
||||
editorRef.current?.clearContent();
|
||||
setContent("");
|
||||
setIsEmpty(true);
|
||||
setSuppressedAgentIds(new Set());
|
||||
setPendingAttachments([]);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -169,14 +163,7 @@ function ReplyInput({
|
||||
!isEmpty && "pb-9",
|
||||
)}
|
||||
>
|
||||
{/* Lock the editor while the reply is in flight — see CommentInput. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto",
|
||||
submitting && "pointer-events-none opacity-60",
|
||||
)}
|
||||
aria-busy={submitting || undefined}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={initialDraft}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
describe("issue icons", () => {
|
||||
it("renders a muted fallback for unknown status values", () => {
|
||||
const { container } = render(<StatusIcon status="unexpected_status" />);
|
||||
|
||||
const icon = container.querySelector("svg");
|
||||
expect(icon).toHaveClass("text-muted-foreground");
|
||||
});
|
||||
|
||||
it("renders a muted fallback for unknown priority values", () => {
|
||||
const { container } = render(<PriorityIcon priority="unexpected_priority" />);
|
||||
|
||||
const icon = container.querySelector("svg");
|
||||
expect(icon).toHaveClass("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
@@ -162,19 +162,18 @@ export function StatusIcon({
|
||||
className = "h-4 w-4",
|
||||
inheritColor = false,
|
||||
}: {
|
||||
status: IssueStatus | string;
|
||||
status: IssueStatus;
|
||||
className?: string;
|
||||
inheritColor?: boolean;
|
||||
}) {
|
||||
const knownStatus = status in STATUS_RENDERERS ? (status as IssueStatus) : null;
|
||||
const cfg = knownStatus ? STATUS_CONFIG[knownStatus] : null;
|
||||
const Renderer = knownStatus ? STATUS_RENDERERS[knownStatus] : TodoIcon;
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const Renderer = STATUS_RENDERERS[status];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
className={`${className} ${inheritColor ? "" : cfg?.iconColor ?? "text-muted-foreground"} shrink-0`}
|
||||
className={`${className} ${inheritColor ? "" : cfg.iconColor} shrink-0`}
|
||||
>
|
||||
<Renderer />
|
||||
</svg>
|
||||
|
||||
@@ -258,31 +258,25 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
|
||||
// --- Mutation functions ---
|
||||
|
||||
// Returns true on success, false on failure. The composer keeps the user's
|
||||
// text (editor locked + button spinning) until this settles and clears only
|
||||
// on success — so a slow send no longer leaves the box full next to an
|
||||
// already-posted comment, and a failed send keeps the draft.
|
||||
const submitComment = useCallback(
|
||||
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
|
||||
if (!content.trim() || !userId) return false;
|
||||
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => {
|
||||
if (!content.trim() || !userId) return;
|
||||
try {
|
||||
await createComment({ content, attachmentIds, suppressAgentIds });
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.send_failed),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[userId, createComment, t],
|
||||
);
|
||||
|
||||
const submitReply = useCallback(
|
||||
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
|
||||
if (!content.trim() || !userId) return false;
|
||||
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => {
|
||||
if (!content.trim() || !userId) return;
|
||||
try {
|
||||
await createComment({
|
||||
content,
|
||||
@@ -291,14 +285,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
attachmentIds,
|
||||
suppressAgentIds,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.send_reply_failed),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[userId, createComment, t],
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"placeholder_default": "Start a message…",
|
||||
"send_tooltip": "Send",
|
||||
"stop_tooltip": "Stop",
|
||||
"send_failed_toast": "Failed to send message",
|
||||
"attachment_bind_failed_toast": "Message sent, but files were not attached. Please try again in a moment."
|
||||
"send_failed_toast": "Failed to send message"
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "Show details",
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"placeholder_default": "メッセージを入力…",
|
||||
"send_tooltip": "送信",
|
||||
"stop_tooltip": "停止",
|
||||
"send_failed_toast": "メッセージを送信できませんでした",
|
||||
"attachment_bind_failed_toast": "メッセージは送信されましたが、ファイルを添付できませんでした。しばらくしてからもう一度お試しください。"
|
||||
"send_failed_toast": "メッセージを送信できませんでした"
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "詳細を表示",
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"placeholder_default": "메시지 입력…",
|
||||
"send_tooltip": "보내기",
|
||||
"stop_tooltip": "중지",
|
||||
"send_failed_toast": "메시지를 보내지 못했습니다",
|
||||
"attachment_bind_failed_toast": "메시지는 보냈지만 파일이 첨부되지 않았습니다. 잠시 후 다시 시도해 주세요."
|
||||
"send_failed_toast": "메시지를 보내지 못했습니다"
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "세부 정보 보기",
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"placeholder_default": "输入消息…",
|
||||
"send_tooltip": "发送",
|
||||
"stop_tooltip": "停止",
|
||||
"send_failed_toast": "发送消息失败",
|
||||
"attachment_bind_failed_toast": "消息已发送,但文件未能附加。请稍后重试。"
|
||||
"send_failed_toast": "发送消息失败"
|
||||
},
|
||||
"message_list": {
|
||||
"show_details": "查看详情",
|
||||
|
||||
@@ -162,7 +162,6 @@ func init() {
|
||||
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
|
||||
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
|
||||
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
|
||||
agentCreateCmd.Flags().String("thinking-level", "", "Reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex: none|minimal|low|medium|high|xhigh). The set is runtime/model-specific and validated server-side — an unknown value is rejected. Empty = runtime default.")
|
||||
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
||||
agentCreateCmd.Flags().String("custom-env", "", "Custom environment variables as JSON object, e.g. '{\"KEY\":\"value\"}'. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --custom-env-stdin or --custom-env-file for real secrets. Pass '{}' to set an empty map.")
|
||||
agentCreateCmd.Flags().Bool("custom-env-stdin", false, "Read the --custom-env JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --custom-env and --custom-env-file.")
|
||||
@@ -181,7 +180,6 @@ func init() {
|
||||
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
|
||||
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
|
||||
agentUpdateCmd.Flags().String("model", "", "New model identifier. Pass an empty string to clear and fall back to the runtime default.")
|
||||
agentUpdateCmd.Flags().String("thinking-level", "", "New reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex: none|minimal|low|medium|high|xhigh). The set is runtime/model-specific and validated server-side. Pass an empty string to clear and fall back to the runtime default.")
|
||||
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
||||
// custom_env is intentionally NOT part of `agent update`. Use
|
||||
// `multica agent env set <id>` — that path is owner/admin-only,
|
||||
@@ -471,14 +469,6 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
}
|
||||
// thinking_level mirrors model: a thin pass-through to the top-level agent
|
||||
// field the server already accepts and validates (IsKnownThinkingValue).
|
||||
// The CLI deliberately does not enumerate valid levels — they are
|
||||
// runtime/model-specific and the server owns the catalog (MUL-2339).
|
||||
if cmd.Flags().Changed("thinking-level") {
|
||||
v, _ := cmd.Flags().GetString("thinking-level")
|
||||
body["thinking_level"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
@@ -548,13 +538,6 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
}
|
||||
// thinking_level is a tri-state on the server (omitted = no change, "" =
|
||||
// clear to runtime default, value = set). Sending the key only when the
|
||||
// flag was provided produces exactly that, the same way --model behaves.
|
||||
if cmd.Flags().Changed("thinking-level") {
|
||||
v, _ := cmd.Flags().GetString("thinking-level")
|
||||
body["thinking_level"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
@@ -574,7 +557,7 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --thinking-level, --custom-args, --mcp-config, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --custom-args, --mcp-config, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
|
||||
}
|
||||
|
||||
ctx, cancel := cli.APIContext(context.Background())
|
||||
|
||||
@@ -1284,201 +1284,3 @@ func TestAgentGetTableIncludesAvatarURL(t *testing.T) {
|
||||
t.Fatalf("table output missing avatar_url value: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCreateSendsThinkingLevel verifies `agent create --thinking-level`
|
||||
// puts the value on the top-level `thinking_level` key of the POST body —
|
||||
// the same field the web inspector and HTTP API already accept. The value is
|
||||
// passed through verbatim; provider-level validation is the server's job
|
||||
// (IsKnownThinkingValue), exactly as `--model` defers model validation.
|
||||
func TestAgentCreateSendsThinkingLevel(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Errorf("decode request body: %v", err)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent", "thinking_level": "high"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_AGENT_ID", "")
|
||||
t.Setenv("MULTICA_TASK_ID", "")
|
||||
|
||||
cmd := &cobra.Command{Use: "create"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("runtime-id", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("instructions", "", "")
|
||||
cmd.Flags().String("thinking-level", "", "")
|
||||
cmd.Flags().String("output", "json", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
_ = cmd.Flags().Set("name", "TestAgent")
|
||||
_ = cmd.Flags().Set("runtime-id", "runtime-1")
|
||||
_ = cmd.Flags().Set("thinking-level", "high")
|
||||
|
||||
if err := runAgentCreate(cmd, nil); err != nil {
|
||||
t.Fatalf("runAgentCreate: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/agents" {
|
||||
t.Fatalf("path = %q, want /api/agents", gotPath)
|
||||
}
|
||||
if gotBody["thinking_level"] != "high" {
|
||||
t.Fatalf("thinking_level body = %v, want high", gotBody["thinking_level"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCreateOmitsThinkingLevelWhenUnset guards the Changed-gated send:
|
||||
// an unset --thinking-level must not appear in the body at all, so the server
|
||||
// applies its default instead of receiving an explicit empty string.
|
||||
func TestAgentCreateOmitsThinkingLevelWhenUnset(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Errorf("decode request body: %v", err)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_AGENT_ID", "")
|
||||
t.Setenv("MULTICA_TASK_ID", "")
|
||||
|
||||
cmd := &cobra.Command{Use: "create"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("runtime-id", "", "")
|
||||
cmd.Flags().String("thinking-level", "", "")
|
||||
cmd.Flags().String("output", "json", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
_ = cmd.Flags().Set("name", "TestAgent")
|
||||
_ = cmd.Flags().Set("runtime-id", "runtime-1")
|
||||
|
||||
if err := runAgentCreate(cmd, nil); err != nil {
|
||||
t.Fatalf("runAgentCreate: %v", err)
|
||||
}
|
||||
if _, ok := gotBody["thinking_level"]; ok {
|
||||
t.Fatalf("unset --thinking-level must be omitted from the body; got %v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentUpdateSendsThinkingLevel covers both update modes that mirror
|
||||
// --model: setting an explicit level, and passing an empty string to clear
|
||||
// back to the runtime default. In both cases the key must be present in the
|
||||
// PUT body — the server reads it as a tri-state pointer (omitted = no change,
|
||||
// "" = clear, value = set), so the CLI's only job is to send the key when the
|
||||
// flag was provided.
|
||||
func TestAgentUpdateSendsThinkingLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"set explicit level", "xhigh"},
|
||||
{"empty string clears", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Errorf("decode request body: %v", err)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent", "thinking_level": tc.value})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_AGENT_ID", "")
|
||||
t.Setenv("MULTICA_TASK_ID", "")
|
||||
|
||||
cmd := &cobra.Command{Use: "update"}
|
||||
cmd.Flags().String("thinking-level", "", "")
|
||||
cmd.Flags().String("output", "json", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
if err := cmd.Flags().Set("thinking-level", tc.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runAgentUpdate(cmd, []string{"agent-123"}); err != nil {
|
||||
t.Fatalf("runAgentUpdate: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPut {
|
||||
t.Fatalf("method = %s, want PUT", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/agents/agent-123" {
|
||||
t.Fatalf("path = %q, want /api/agents/agent-123", gotPath)
|
||||
}
|
||||
v, ok := gotBody["thinking_level"]
|
||||
if !ok {
|
||||
t.Fatalf("body missing thinking_level key; got %v", gotBody)
|
||||
}
|
||||
if v != tc.value {
|
||||
t.Fatalf("thinking_level body = %v, want %q", v, tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCreateAndUpdateExposeThinkingLevelFlag guarantees the flag stays
|
||||
// wired on both write surfaces. The read side (`agent get`) already exposes
|
||||
// thinking_level; this is the matching write surface (#4170).
|
||||
func TestAgentCreateAndUpdateExposeThinkingLevelFlag(t *testing.T) {
|
||||
if agentCreateCmd.Flag("thinking-level") == nil {
|
||||
t.Error("agent create must expose --thinking-level")
|
||||
}
|
||||
if agentUpdateCmd.Flag("thinking-level") == nil {
|
||||
t.Error("agent update must expose --thinking-level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCreateThinkingLevelServerRejectionSurfaces proves the CLI does not
|
||||
// own thinking-level validation: a runtime whose provider has no thinking
|
||||
// concept (or an unknown literal) is rejected server-side with a 400, and that
|
||||
// message must reach the user rather than being swallowed. This is why the CLI
|
||||
// can stay a thin pass-through — the server already owns the (provider, model)
|
||||
// catalog (server/pkg/agent/thinking.go).
|
||||
func TestAgentCreateThinkingLevelServerRejectionSurfaces(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
io.WriteString(w, `{"error":"thinking_level \"max\" is not a recognised value for runtime \"gemini\""}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_AGENT_ID", "")
|
||||
t.Setenv("MULTICA_TASK_ID", "")
|
||||
|
||||
cmd := &cobra.Command{Use: "create"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("runtime-id", "", "")
|
||||
cmd.Flags().String("thinking-level", "", "")
|
||||
cmd.Flags().String("output", "json", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
_ = cmd.Flags().Set("name", "TestAgent")
|
||||
_ = cmd.Flags().Set("runtime-id", "runtime-gemini")
|
||||
_ = cmd.Flags().Set("thinking-level", "max")
|
||||
|
||||
err := runAgentCreate(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server rejects thinking_level, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a recognised value for runtime") {
|
||||
t.Fatalf("server thinking_level rejection should surface to the user; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,162 +441,6 @@ func TestCancelTaskByUser_ChatTaskWithoutTranscript_RestoresUserDraft(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelTaskByUser_ChatTaskWithBoundAttachment_SurvivesCancelAndRebinds
|
||||
// guards the data-loss path on the empty-chat cancel: the user message bound to
|
||||
// an attachment is deleted, and attachment.chat_message_id is ON DELETE CASCADE
|
||||
// (server/migrations/083_attachment_chat_columns.up.sql), so without the
|
||||
// detach-before-delete step the cancel would silently destroy the user's
|
||||
// attachment. The detach (chat_message_id -> NULL, chat_session_id retained) is
|
||||
// load-bearing, not an optimization; nothing else covered it. This pins:
|
||||
//
|
||||
// (a) the attachment row survives the cascade — still present, chat_message_id
|
||||
// NULL, chat_session_id retained;
|
||||
// (b) the cancel response returns it via cancelled_chat_message.attachments so
|
||||
// the restored draft can re-show it;
|
||||
// (c) re-sending the restored draft re-binds the surviving attachment to the
|
||||
// new message in the same session.
|
||||
func TestCancelTaskByUser_ChatTaskWithBoundAttachment_SurvivesCancelAndRebinds(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID := createHandlerTestAgent(t, "CancelChatAttachAgent", []byte("[]"))
|
||||
sessionID := createHandlerTestChatSession(t, agentID)
|
||||
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id)
|
||||
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2)
|
||||
RETURNING id
|
||||
`, agentID, sessionID).Scan(&taskID); err != nil {
|
||||
t.Fatalf("create chat task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
||||
|
||||
var userMessageID string
|
||||
const userContent = "look at this attachment"
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO chat_message (chat_session_id, role, content, task_id)
|
||||
VALUES ($1, 'user', $2, $3)
|
||||
RETURNING id
|
||||
`, sessionID, userContent, taskID).Scan(&userMessageID); err != nil {
|
||||
t.Fatalf("create linked user chat message: %v", err)
|
||||
}
|
||||
|
||||
// Bind an attachment to that user message, exactly as a real send does:
|
||||
// workspace-scoped, uploaded by the session creator, pointing at both the
|
||||
// session and the message.
|
||||
var attachmentID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO attachment (workspace_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, chat_session_id, chat_message_id)
|
||||
VALUES ($1, 'member', $2, 'cancel-survive.png', 'https://cdn.example.com/cancel-survive.png', 'image/png', 9, $3, $4)
|
||||
RETURNING id::text
|
||||
`, testWorkspaceID, testUserID, sessionID, userMessageID).Scan(&attachmentID); err != nil {
|
||||
t.Fatalf("seed bound attachment: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID) })
|
||||
|
||||
// Cancel the empty chat task (no transcript) — this deletes the user message.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp CancelTaskByUserResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode cancel response: %v", err)
|
||||
}
|
||||
if resp.CancelledChatMessage == nil {
|
||||
t.Fatal("expected restore payload for empty transcript cancel")
|
||||
}
|
||||
|
||||
// (b) The cancel response carries the detached attachment back.
|
||||
var returned *AttachmentResponse
|
||||
for i := range resp.CancelledChatMessage.Attachments {
|
||||
if resp.CancelledChatMessage.Attachments[i].ID == attachmentID {
|
||||
returned = &resp.CancelledChatMessage.Attachments[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if returned == nil {
|
||||
t.Fatalf("cancel response did not return the detached attachment: %#v", resp.CancelledChatMessage.Attachments)
|
||||
}
|
||||
|
||||
// (a) The row survived the ON DELETE CASCADE: still present, detached from
|
||||
// the deleted message, but still scoped to the session.
|
||||
var count int
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM attachment WHERE id = $1`, attachmentID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count attachment: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("attachment was cascade-deleted on cancel: count = %d", count)
|
||||
}
|
||||
var dbMessageID, dbSessionID *string
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT chat_message_id::text, chat_session_id::text FROM attachment WHERE id = $1`, attachmentID,
|
||||
).Scan(&dbMessageID, &dbSessionID); err != nil {
|
||||
t.Fatalf("read attachment after cancel: %v", err)
|
||||
}
|
||||
if dbMessageID != nil {
|
||||
t.Fatalf("expected chat_message_id detached to NULL, got %q", *dbMessageID)
|
||||
}
|
||||
if dbSessionID == nil || *dbSessionID != sessionID {
|
||||
t.Fatalf("expected chat_session_id retained as %q, got %v", sessionID, dbSessionID)
|
||||
}
|
||||
|
||||
// Sanity: the empty-cancel still deleted the user message itself.
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM chat_message WHERE id = $1`, userMessageID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count user message: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected linked user message to be deleted, got %d", count)
|
||||
}
|
||||
|
||||
// (c) Re-sending the restored draft re-binds the surviving attachment to a
|
||||
// fresh message in the same session — the whole reason for detaching.
|
||||
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
||||
"content": userContent,
|
||||
"attachment_ids": []string{attachmentID},
|
||||
})
|
||||
sendReq = withURLParam(sendReq, "sessionId", sessionID)
|
||||
sendReq = withChatTestWorkspaceCtx(t, sendReq)
|
||||
sendW := httptest.NewRecorder()
|
||||
testHandler.SendChatMessage(sendW, sendReq)
|
||||
if sendW.Code != http.StatusCreated {
|
||||
t.Fatalf("resend: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
|
||||
}
|
||||
var sendResp SendChatMessageResponse
|
||||
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
|
||||
t.Fatalf("decode resend response: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, sendResp.TaskID)
|
||||
})
|
||||
|
||||
rebound := false
|
||||
for _, id := range sendResp.AttachmentIDs {
|
||||
if id == attachmentID {
|
||||
rebound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rebound {
|
||||
t.Fatalf("attachment not re-bound on resend: %#v", sendResp.AttachmentIDs)
|
||||
}
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT chat_message_id::text FROM attachment WHERE id = $1`, attachmentID,
|
||||
).Scan(&dbMessageID); err != nil {
|
||||
t.Fatalf("read attachment after resend: %v", err)
|
||||
}
|
||||
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
|
||||
t.Fatalf("expected attachment re-bound to new message %q, got %v", sendResp.MessageID, dbMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelTaskByUser_PrivateAgent_PlainMember_Returns403 verifies the cancel
|
||||
// endpoint mirrors the agent Activity / snapshot visibility gate: a plain
|
||||
// member who cannot see a private agent's tasks cannot cancel them either.
|
||||
|
||||
@@ -386,16 +386,6 @@ type SendChatMessageRequest struct {
|
||||
type SendChatMessageResponse struct {
|
||||
MessageID string `json:"message_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
// AttachmentIDs are the attachment rows actually bound to this message by
|
||||
// the server. The client diffs these against the ids it requested so it
|
||||
// can warn the user when an attachment silently failed to bind — no extra
|
||||
// round-trip needed. No `omitempty`: a send that requested attachments but
|
||||
// bound none must serialize `[]` (not be omitted), otherwise the client
|
||||
// can't tell "all binds failed" from "older server without this field" and
|
||||
// would silently skip the very warning this exists for. When no
|
||||
// attachments were requested the value is nil → `null`, which the client's
|
||||
// guard short-circuits on the requested-ids check.
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
// CreatedAt anchors the chat StatusPill timer the instant the user
|
||||
// hits send. Without it the front-end falls back to its local clock
|
||||
// and the timer "snaps backwards" later when WS events deliver the
|
||||
@@ -459,31 +449,20 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Back-fill chat_message_id on attachments the sender uploaded while
|
||||
// composing. New clients upload workspace-scoped unattached rows and bind
|
||||
// them here; older clients may still upload against the chat_session_id.
|
||||
// The query accepts both shapes, but only for this workspace, this actor,
|
||||
// and rows that are not already linked to an issue/comment/message.
|
||||
var boundAttachmentIDs []string
|
||||
// Back-fill chat_message_id on attachments that were uploaded against
|
||||
// this session while the user was composing. The query only touches rows
|
||||
// where chat_session_id matches AND chat_message_id IS NULL, so it cannot
|
||||
// rebind an attachment that already belongs to an earlier message.
|
||||
if len(attachmentIDs) > 0 {
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
bound, err := h.Queries.LinkAttachmentsToChatMessage(r.Context(), db.LinkAttachmentsToChatMessageParams{
|
||||
if err := h.Queries.LinkAttachmentsToChatMessage(r.Context(), db.LinkAttachmentsToChatMessageParams{
|
||||
ChatMessageID: msg.ID,
|
||||
ChatSessionID: session.ID,
|
||||
WorkspaceID: session.WorkspaceID,
|
||||
UploaderType: actorType,
|
||||
UploaderID: parseUUID(actorID),
|
||||
AttachmentIds: attachmentIDs,
|
||||
})
|
||||
if err != nil {
|
||||
Column3: attachmentIDs,
|
||||
}); err != nil {
|
||||
// Don't fail the send — the message content is already saved and
|
||||
// the attachments remain on the session (still downloadable).
|
||||
slog.Warn("link chat attachments failed", "error", err, "message_id", uuidToString(msg.ID))
|
||||
}
|
||||
boundAttachmentIDs = make([]string, 0, len(bound))
|
||||
for _, id := range bound {
|
||||
boundAttachmentIDs = append(boundAttachmentIDs, uuidToString(id))
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue a chat task after the message exists. For web chat the sender is
|
||||
@@ -537,10 +516,9 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, SendChatMessageResponse{
|
||||
MessageID: uuidToString(msg.ID),
|
||||
TaskID: uuidToString(task.ID),
|
||||
CreatedAt: timestampToString(task.CreatedAt),
|
||||
AttachmentIDs: boundAttachmentIDs,
|
||||
MessageID: uuidToString(msg.ID),
|
||||
TaskID: uuidToString(task.ID),
|
||||
CreatedAt: timestampToString(task.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -736,11 +714,10 @@ type PendingChatTaskItem struct {
|
||||
}
|
||||
|
||||
type CancelledChatMessageResponse struct {
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
Content string `json:"content"`
|
||||
RestoreToInput bool `json:"restore_to_input"`
|
||||
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
Content string `json:"content"`
|
||||
RestoreToInput bool `json:"restore_to_input"`
|
||||
}
|
||||
|
||||
type CancelTaskByUserResponse struct {
|
||||
@@ -937,16 +914,11 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
|
||||
AgentTaskResponse: taskToResponse(cancelled.Task, workspaceID),
|
||||
}
|
||||
if cancelled.CancelledChatMessage != nil {
|
||||
attachments := make([]AttachmentResponse, 0, len(cancelled.CancelledChatMessage.Attachments))
|
||||
for _, a := range cancelled.CancelledChatMessage.Attachments {
|
||||
attachments = append(attachments, h.attachmentToResponse(a))
|
||||
}
|
||||
resp.CancelledChatMessage = &CancelledChatMessageResponse{
|
||||
ChatSessionID: cancelled.CancelledChatMessage.ChatSessionID,
|
||||
MessageID: cancelled.CancelledChatMessage.MessageID,
|
||||
Content: cancelled.CancelledChatMessage.Content,
|
||||
RestoreToInput: cancelled.CancelledChatMessage.RestoreToInput,
|
||||
Attachments: attachments,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,81 +123,6 @@ func TestSendChatMessage_LinksAttachments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendChatMessage_LinksUnattachedAttachments verifies the new compose
|
||||
// path: upload creates a workspace-scoped unattached attachment, and chat send
|
||||
// binds it to both the session and the user message.
|
||||
func TestSendChatMessage_LinksUnattachedAttachments(t *testing.T) {
|
||||
origStorage := testHandler.Storage
|
||||
testHandler.Storage = &mockStorage{}
|
||||
defer func() { testHandler.Storage = origStorage }()
|
||||
|
||||
agentID := createHandlerTestAgent(t, "ChatSendUnattachedAttachAgent", []byte("[]"))
|
||||
sessionID := createHandlerTestChatSession(t, agentID)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, _ := writer.CreateFormFile("file", "send-unattached.png")
|
||||
part.Write([]byte("\x89PNG\r\n\x1a\nbytes"))
|
||||
writer.Close()
|
||||
|
||||
uploadReq := httptest.NewRequest("POST", "/api/upload-file", &body)
|
||||
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
uploadReq.Header.Set("X-User-ID", testUserID)
|
||||
uploadReq.Header.Set("X-Workspace-ID", testWorkspaceID)
|
||||
|
||||
uploadW := httptest.NewRecorder()
|
||||
testHandler.UploadFile(uploadW, uploadReq)
|
||||
if uploadW.Code != http.StatusOK {
|
||||
t.Fatalf("upload precondition: %d %s", uploadW.Code, uploadW.Body.String())
|
||||
}
|
||||
var uploadResp AttachmentResponse
|
||||
if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResp); err != nil {
|
||||
t.Fatalf("decode upload: %v", err)
|
||||
}
|
||||
attachmentID := uploadResp.ID
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID)
|
||||
})
|
||||
if uploadResp.ChatSessionID != nil {
|
||||
t.Fatalf("pre-send chat_session_id should be nil, got %v", *uploadResp.ChatSessionID)
|
||||
}
|
||||
if uploadResp.ChatMessageID != nil {
|
||||
t.Fatalf("pre-send chat_message_id should be nil, got %v", *uploadResp.ChatMessageID)
|
||||
}
|
||||
|
||||
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
|
||||
"content": "look at this ",
|
||||
"attachment_ids": []string{attachmentID},
|
||||
})
|
||||
sendReq = withURLParam(sendReq, "sessionId", sessionID)
|
||||
sendReq = withChatTestWorkspaceCtx(t, sendReq)
|
||||
sendW := httptest.NewRecorder()
|
||||
testHandler.SendChatMessage(sendW, sendReq)
|
||||
if sendW.Code != http.StatusCreated {
|
||||
t.Fatalf("SendChatMessage: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
|
||||
}
|
||||
|
||||
var sendResp SendChatMessageResponse
|
||||
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
|
||||
t.Fatalf("decode send: %v", err)
|
||||
}
|
||||
|
||||
var dbSessionID, dbMessageID *string
|
||||
if err := testPool.QueryRow(
|
||||
context.Background(),
|
||||
`SELECT chat_session_id::text, chat_message_id::text FROM attachment WHERE id = $1`,
|
||||
attachmentID,
|
||||
).Scan(&dbSessionID, &dbMessageID); err != nil {
|
||||
t.Fatalf("query attachment: %v", err)
|
||||
}
|
||||
if dbSessionID == nil || *dbSessionID != sessionID {
|
||||
t.Fatalf("chat_session_id mismatch: want %s, got %v", sessionID, dbSessionID)
|
||||
}
|
||||
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
|
||||
t.Fatalf("chat_message_id mismatch: want %s, got %v", sendResp.MessageID, dbMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title,
|
||||
// returns the updated row, and the server-side row reflects it.
|
||||
func TestUpdateChatSession_RenamesTitle(t *testing.T) {
|
||||
|
||||
@@ -58,8 +58,8 @@ multica agent create --name <name> --runtime-id <runtime-id> \
|
||||
`runAgentCreate` builds a JSON body and posts it to `/api/agents`. It only
|
||||
adds a key when its flag was provided — `description`/`instructions` on a
|
||||
non-empty value, the rest (`runtime-config`, `custom-args`, `model`,
|
||||
`thinking-level`, `visibility`, …) on the flag being `Changed` — so omitted
|
||||
flags fall through to server defaults rather than sending empty strings.
|
||||
`visibility`, …) on the flag being `Changed` — so omitted flags fall through
|
||||
to server defaults rather than sending empty strings.
|
||||
|
||||
The HTTP body (`CreateAgentRequest`) accepts: `name`, `description`,
|
||||
`instructions`, `runtime_id`, `runtime_config`, `custom_env`, `custom_args`,
|
||||
@@ -93,16 +93,6 @@ literal returns 400, but a value that is valid for the provider yet
|
||||
unsupported for the chosen model is NOT rejected here — that gap surfaces as a
|
||||
daemon-side task error at execution time.
|
||||
|
||||
Set it from the CLI with `--thinking-level` on `agent create` and `agent
|
||||
update`, mirroring `--model`: the flag is a thin pass-through to the top-level
|
||||
`thinking_level` field, and on update an empty string (`--thinking-level ""`)
|
||||
clears it back to the runtime default. The CLI deliberately does not enumerate
|
||||
the valid levels — they are runtime/model-specific (Claude
|
||||
`low|medium|high|xhigh|max`, Codex `none|minimal|low|medium|high|xhigh`, and
|
||||
others), so it forwards whatever you pass and lets the server's provider
|
||||
catalog accept or reject it. A runtime whose provider has no thinking concept
|
||||
rejects any non-empty value with a 400.
|
||||
|
||||
### model vs custom_args
|
||||
|
||||
`model` is a first-class persisted column the daemon reads directly.
|
||||
|
||||
@@ -19,20 +19,19 @@ go test ./internal/service -run TestBuiltinSkillsConformToTemplate
|
||||
| Contract | Line | Behavior | Safe check |
|
||||
|---|---|---|---|
|
||||
| Create flags: `name`, `description`, `instructions`, `runtime-id` | 159–162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
|
||||
| `runtime-config`, `model`, `thinking-level`, `custom-args` flags | 163–166 | `model` help: "Prefer this over passing --model in --custom-args"; `thinking-level` is a thin pass-through (server validates the provider enum, empty = runtime default); `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
|
||||
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 167–169 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
|
||||
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 170–172 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
|
||||
| MCP flags on `agent update` | 194–196 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
|
||||
| `thinking-level` flag on `agent update` | 184 | New reasoning/effort level; thin pass-through; `--thinking-level ""` clears to runtime default (mirrors `--model`) | `multica agent update --help` |
|
||||
| `runAgentCreate` builds body + `POST /api/agents` | 419 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 495) | read 419–496 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model/thinking-level | 438–488 | `resolveCustomEnv` (460) and `resolveMcpConfig` (465) gate their secret channels; `model` (470) and `thinking_level` (478) are `Changed`-gated pass-throughs; omitted flags are not sent | read 438–488 |
|
||||
| `runAgentUpdate` sends `thinking_level` / `mcp_config` | 508 | `thinking_level` added when `--thinking-level` is `Changed` (556); `resolveMcpConfig` adds `mcp_config` (570); `PUT /api/agents/{id}` at 584; `custom_env` is intentionally not a flag here | read 508–585 |
|
||||
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1086, 1114 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 1086–1170 |
|
||||
| `agent skills set` = replace-all | 792 | `PUT /api/agents/{id}/skills` (810); `--skill-ids ''` clears all (798–799) | `multica agent skills set --help` |
|
||||
| `agent skills add` = additive | 817 | `POST /api/agents/{id}/skills/add` (838); requires ≥1 id (823–828) | `multica agent skills add --help` |
|
||||
| `agent skills list` | 760 | reads bindings, no side effect | `multica agent skills list --help` |
|
||||
| `agent env get` | 894 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
|
||||
| `agent env set` | 929 | `PUT /api/agents/{id}/env` with full `custom_env` map (935, 949) | `multica agent env set --help` |
|
||||
| `runtime-config`, `model`, `custom-args` flags | 163–165 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
|
||||
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 166–168 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
|
||||
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 169–171 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
|
||||
| MCP flags on `agent update` | 192–194 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
|
||||
| `runAgentCreate` builds body + `POST /api/agents` | 414 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 482) | read 414–489 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 437–478 | `resolveCustomEnv` (455) and `resolveMcpConfig` (460) gate their secret channels; omitted flags are not sent | read 437–478 |
|
||||
| `runAgentUpdate` sends `mcp_config` | 550 | `resolveMcpConfig` adds `mcp_config` to the `PUT /api/agents/{id}` body (564); `custom_env` is intentionally not a flag here | read 495–565 |
|
||||
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1066, 1094 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 1066–1150 |
|
||||
| `agent skills set` = replace-all | 772 | `PUT /api/agents/{id}/skills` (790); `--skill-ids ''` clears all (779) | `multica agent skills set --help` |
|
||||
| `agent skills add` = additive | 797 | `POST /api/agents/{id}/skills/add` (818); requires ≥1 id (804, 808) | `multica agent skills add --help` |
|
||||
| `agent skills list` | 740 | reads bindings, no side effect | `multica agent skills list --help` |
|
||||
| `agent env get` | 874 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
|
||||
| `agent env set` | 909 | `PUT /api/agents/{id}/env` with full `custom_env` map (923, 929) | `multica agent env set --help` |
|
||||
|
||||
Note: the CLI no longer exposes `--from-template`. The agent-template backend
|
||||
still exists (registry `server/internal/agenttmpl/`, handler `agent_template.go`,
|
||||
|
||||
@@ -825,10 +825,6 @@ type CancelledChatMessageResult struct {
|
||||
MessageID string
|
||||
Content string
|
||||
RestoreToInput bool
|
||||
// Attachments are the rows detached from the deleted user message so they
|
||||
// survive the ON DELETE CASCADE and can re-bind when the restored draft is
|
||||
// re-sent.
|
||||
Attachments []db.Attachment
|
||||
}
|
||||
|
||||
type CancelTaskResult struct {
|
||||
@@ -888,13 +884,6 @@ func (s *TaskService) finalizeCancelledChatMessage(ctx context.Context, task db.
|
||||
return fmt.Errorf("list cancelled chat task messages: %w", err)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
// Detach attachments BEFORE deleting the user message — the
|
||||
// attachment FK is ON DELETE CASCADE, so deleting first would
|
||||
// destroy rows the restored draft needs to re-bind.
|
||||
detached, err := qtx.DetachAttachmentsFromUserChatMessageByTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("detach cancelled chat message attachments: %w", err)
|
||||
}
|
||||
deleted, err := qtx.DeleteUserChatMessageByTask(ctx, task.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
@@ -907,7 +896,6 @@ func (s *TaskService) finalizeCancelledChatMessage(ctx context.Context, task db.
|
||||
MessageID: util.UUIDToString(deleted.ID),
|
||||
Content: deleted.Content,
|
||||
RestoreToInput: true,
|
||||
Attachments: detached,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -84,54 +84,6 @@ func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentPara
|
||||
return err
|
||||
}
|
||||
|
||||
const detachAttachmentsFromUserChatMessageByTask = `-- name: DetachAttachmentsFromUserChatMessageByTask :many
|
||||
UPDATE attachment
|
||||
SET chat_message_id = NULL
|
||||
WHERE chat_message_id IN (
|
||||
SELECT id FROM chat_message WHERE task_id = $1 AND role = 'user'
|
||||
)
|
||||
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id
|
||||
`
|
||||
|
||||
// When an empty chat task is cancelled, its user message is deleted. The
|
||||
// attachment FK is ON DELETE CASCADE, so without this the bound rows would be
|
||||
// destroyed and a restored draft could never re-bind them. Detach first
|
||||
// (chat_message_id -> NULL, keep chat_session_id) so the rows survive as
|
||||
// workspace/session-scoped unattached attachments and re-send can re-link them.
|
||||
func (q *Queries) DetachAttachmentsFromUserChatMessageByTask(ctx context.Context, taskID pgtype.UUID) ([]Attachment, error) {
|
||||
rows, err := q.db.Query(ctx, detachAttachmentsFromUserChatMessageByTask, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Attachment{}
|
||||
for rows.Next() {
|
||||
var i Attachment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.CommentID,
|
||||
&i.UploaderType,
|
||||
&i.UploaderID,
|
||||
&i.Filename,
|
||||
&i.Url,
|
||||
&i.ContentType,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
&i.ChatSessionID,
|
||||
&i.ChatMessageID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAttachment = `-- name: GetAttachment :one
|
||||
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id FROM attachment
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
@@ -195,58 +147,23 @@ func (q *Queries) GetAttachmentByIDOnly(ctx context.Context, id pgtype.UUID) (At
|
||||
return i, err
|
||||
}
|
||||
|
||||
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :many
|
||||
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :exec
|
||||
UPDATE attachment
|
||||
SET chat_message_id = $1,
|
||||
chat_session_id = $2
|
||||
WHERE workspace_id = $3
|
||||
AND issue_id IS NULL
|
||||
AND comment_id IS NULL
|
||||
SET chat_message_id = $1
|
||||
WHERE chat_session_id = $2
|
||||
AND chat_message_id IS NULL
|
||||
AND (
|
||||
chat_session_id IS NULL
|
||||
OR chat_session_id = $2
|
||||
)
|
||||
AND uploader_type = $4
|
||||
AND uploader_id = $5
|
||||
AND id = ANY($6::uuid[])
|
||||
RETURNING id
|
||||
AND id = ANY($3::uuid[])
|
||||
`
|
||||
|
||||
type LinkAttachmentsToChatMessageParams struct {
|
||||
ChatMessageID pgtype.UUID `json:"chat_message_id"`
|
||||
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UploaderType string `json:"uploader_type"`
|
||||
UploaderID pgtype.UUID `json:"uploader_id"`
|
||||
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
|
||||
Column3 []pgtype.UUID `json:"column_3"`
|
||||
}
|
||||
|
||||
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, linkAttachmentsToChatMessage,
|
||||
arg.ChatMessageID,
|
||||
arg.ChatSessionID,
|
||||
arg.WorkspaceID,
|
||||
arg.UploaderType,
|
||||
arg.UploaderID,
|
||||
arg.AttachmentIds,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []pgtype.UUID{}
|
||||
for rows.Next() {
|
||||
var id pgtype.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) error {
|
||||
_, err := q.db.Exec(ctx, linkAttachmentsToChatMessage, arg.ChatMessageID, arg.ChatSessionID, arg.Column3)
|
||||
return err
|
||||
}
|
||||
|
||||
const linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec
|
||||
|
||||
@@ -66,35 +66,12 @@ WHERE issue_id = $2
|
||||
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
|
||||
);
|
||||
|
||||
-- name: LinkAttachmentsToChatMessage :many
|
||||
-- name: LinkAttachmentsToChatMessage :exec
|
||||
UPDATE attachment
|
||||
SET chat_message_id = sqlc.arg(chat_message_id),
|
||||
chat_session_id = sqlc.arg(chat_session_id)
|
||||
WHERE workspace_id = sqlc.arg(workspace_id)
|
||||
AND issue_id IS NULL
|
||||
AND comment_id IS NULL
|
||||
SET chat_message_id = $1
|
||||
WHERE chat_session_id = $2
|
||||
AND chat_message_id IS NULL
|
||||
AND (
|
||||
chat_session_id IS NULL
|
||||
OR chat_session_id = sqlc.arg(chat_session_id)
|
||||
)
|
||||
AND uploader_type = sqlc.arg(uploader_type)
|
||||
AND uploader_id = sqlc.arg(uploader_id)
|
||||
AND id = ANY(sqlc.arg(attachment_ids)::uuid[])
|
||||
RETURNING id;
|
||||
|
||||
-- name: DetachAttachmentsFromUserChatMessageByTask :many
|
||||
-- When an empty chat task is cancelled, its user message is deleted. The
|
||||
-- attachment FK is ON DELETE CASCADE, so without this the bound rows would be
|
||||
-- destroyed and a restored draft could never re-bind them. Detach first
|
||||
-- (chat_message_id -> NULL, keep chat_session_id) so the rows survive as
|
||||
-- workspace/session-scoped unattached attachments and re-send can re-link them.
|
||||
UPDATE attachment
|
||||
SET chat_message_id = NULL
|
||||
WHERE chat_message_id IN (
|
||||
SELECT id FROM chat_message WHERE task_id = $1 AND role = 'user'
|
||||
)
|
||||
RETURNING *;
|
||||
AND id = ANY($3::uuid[]);
|
||||
|
||||
-- name: ListAttachmentsByChatMessage :many
|
||||
SELECT * FROM attachment
|
||||
|
||||
Reference in New Issue
Block a user