From 64221f866a0d2ebf9882a3eea9c49affbe74d731 Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Thu, 6 Aug 2026 11:42:32 +0700 Subject: [PATCH 1/3] feat(assistant): show bounded skill activity receipts --- ARCHITECTURE.md | 7 + .../api/assistant/AssistantController.java | 4 +- .../api/assistant/AssistantStreamPart.java | 8 +- .../api/assistant/UiMessageStream.java | 19 ++- .../api/assistant/UiMessageStreamTests.java | 6 +- .../features/assistant/assistant-activity.ts | 59 ++++++++- .../components/assistant-page.test.ts | 90 ++++++++++++- .../assistant/components/assistant-page.tsx | 121 +++++++++++++++--- .../assistant-skill-activity.test.tsx | 48 +++++++ .../components/assistant-skill-activity.tsx | 101 +++++++++++++++ apps/web/test/e2e/assistant-pipeline.spec.ts | 41 +++++- .../assistant/AssistantAgentActivity.java | 51 +++++++- .../AssistantAgentActivityTests.java | 42 ++++++ ...nded-successful-skill-activity-receipts.md | 48 +++++++ .../challenge-brief.md | 71 ++++++++++ .../challenge-verdict.md | 49 +++++++ .../design.md | 105 +++++++++++++++ .../plan.md | 17 +++ .../verification.md | 42 ++++++ docs/roadmap.md | 1 + docs/specs/domains/assistant-and-mcp.md | 23 +++- docs/tests/domains/assistant-and-mcp.md | 7 +- .../gateway/AssistantSkillToolCallbacks.java | 68 ++++++++-- .../AssistantSkillToolCallbacksTests.java | 56 +++++++- 24 files changed, 1028 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/features/assistant/components/assistant-skill-activity.test.tsx create mode 100644 apps/web/src/features/assistant/components/assistant-skill-activity.tsx create mode 100644 core/src/test/java/com/orgmemory/core/assistant/AssistantAgentActivityTests.java create mode 100644 docs/decisions/0033-bounded-successful-skill-activity-receipts.md create mode 100644 docs/increments/active/2026-08-06-assistant-skill-activity-receipt/challenge-brief.md create mode 100644 docs/increments/active/2026-08-06-assistant-skill-activity-receipt/challenge-verdict.md create mode 100644 docs/increments/active/2026-08-06-assistant-skill-activity-receipt/design.md create mode 100644 docs/increments/active/2026-08-06-assistant-skill-activity-receipt/plan.md create mode 100644 docs/increments/active/2026-08-06-assistant-skill-activity-receipt/verification.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d8eb4078..ffbda957 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -426,6 +426,13 @@ stored object keys and denied identities never enter model context. Skill content is untrusted, `allowed-tools` grants no runtime authority, and the API does not execute scripts, binaries, shell commands, or package code. Empty authorized retrieval still terminates before model or Skill-tool invocation. +Successful activation may emit one transient, server-sanitized Skill title and +a positive turn-local ordinal for the browser's current-turn receipt. Search, +denial, and failure remain unnamed; resource activity is attributable only to +an exact release activated successfully in that turn. The receipt is never +persisted or reconstructed from conversation history. A browser-owned +visible-output latch keeps the waiting row mounted across transport completion +until answer text is actually visible; a source frame alone does not end it. The pure-Java GraphRAG core defines canonical entity/relation identity, evidence-level contributions and provenance, structured extraction contracts, diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java index ba4d192f..be843f49 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantController.java @@ -457,6 +457,8 @@ private static AssistantStreamPart activityPart( return new AssistantStreamPart.Activity( AssistantStreamPart.Activity.Phase.valueOf(activity.phase().name()), AssistantStreamPart.Activity.State.valueOf(activity.state().name()), - activity.resultCount()); + activity.resultCount(), + activity.skillOrdinal(), + activity.skillTitle()); } } diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantStreamPart.java b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantStreamPart.java index a8b8b8cf..9bb31156 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantStreamPart.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/AssistantStreamPart.java @@ -11,7 +11,13 @@ record FinishStep() implements AssistantStreamPart { record Activity( Phase phase, State state, - Integer evidenceCount) implements AssistantStreamPart { + Integer evidenceCount, + Integer skillOrdinal, + String skillTitle) implements AssistantStreamPart { + + Activity(Phase phase, State state, Integer evidenceCount) { + this(phase, state, evidenceCount, null, null); + } enum Phase { RETRIEVAL, diff --git a/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java b/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java index 34b7394f..5975c3e9 100644 --- a/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java +++ b/apps/api/src/main/java/com/orgmemory/api/assistant/UiMessageStream.java @@ -100,10 +100,7 @@ private static Map payload(AssistantStreamPart part) { case AssistantStreamPart.FinishStep ignored -> fields("type", "finish-step"); case AssistantStreamPart.Activity activity -> fields( "type", "data-assistantActivity", - "data", fields( - "phase", activity.phase().name(), - "state", activity.state().name(), - "evidenceCount", activity.evidenceCount()), + "data", activityFields(activity), "transient", true); case AssistantStreamPart.TextStart text -> fields("type", "text-start", "id", text.id()); case AssistantStreamPart.TextDelta text -> fields( @@ -121,6 +118,20 @@ private static Map payload(AssistantStreamPart part) { }; } + private static Map activityFields(AssistantStreamPart.Activity activity) { + Map values = fields( + "phase", activity.phase().name(), + "state", activity.state().name(), + "evidenceCount", activity.evidenceCount()); + if (activity.skillOrdinal() != null) { + values.put("skillOrdinal", activity.skillOrdinal()); + } + if (activity.skillTitle() != null) { + values.put("skillTitle", activity.skillTitle()); + } + return values; + } + private static ServerSentEvent event(String data) { return ServerSentEvent.builder(data).build(); } diff --git a/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java b/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java index dca1385b..182e707c 100644 --- a/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java +++ b/apps/api/src/test/java/com/orgmemory/api/assistant/UiMessageStreamTests.java @@ -59,7 +59,9 @@ void emitsTransientSkillToolActivityWithoutPersistingToolPayloads() { Flux.just(new AssistantStreamPart.Activity( AssistantStreamPart.Activity.Phase.SKILL_ACTIVATION, AssistantStreamPart.Activity.State.COMPLETE, - 1)), + null, + 1, + "Incident response")), MESSAGE_ID, json, Duration.ofHours(1), @@ -69,7 +71,7 @@ void emitsTransientSkillToolActivityWithoutPersistingToolPayloads() { .block(); assertThat(data).contains( - "{\"type\":\"data-assistantActivity\",\"data\":{\"phase\":\"SKILL_ACTIVATION\",\"state\":\"COMPLETE\",\"evidenceCount\":1},\"transient\":true}"); + "{\"type\":\"data-assistantActivity\",\"data\":{\"phase\":\"SKILL_ACTIVATION\",\"state\":\"COMPLETE\",\"evidenceCount\":null,\"skillOrdinal\":1,\"skillTitle\":\"Incident response\"},\"transient\":true}"); } @Test diff --git a/apps/web/src/features/assistant/assistant-activity.ts b/apps/web/src/features/assistant/assistant-activity.ts index 4d78a55b..d5b5e310 100644 --- a/apps/web/src/features/assistant/assistant-activity.ts +++ b/apps/web/src/features/assistant/assistant-activity.ts @@ -1,3 +1,5 @@ +import type { UIMessage } from "ai" + export interface AssistantActivity { phase: | "RETRIEVAL" @@ -7,6 +9,8 @@ export interface AssistantActivity { | "SKILL_RESOURCE" state: "ACTIVE" | "COMPLETE" | "FAILED" evidenceCount?: number | null + skillOrdinal?: number | null + skillTitle?: string | null } export function activityLabel(activity: AssistantActivity | null) { @@ -27,12 +31,63 @@ export function activityLabel(activity: AssistantActivity | null) { if (activity.phase === "SKILL_ACTIVATION") { if (activity.state === "ACTIVE") return "Loading skill instructions…" if (activity.state === "FAILED") return "Skill unavailable — continuing…" - return "Skill instructions ready" + return "Preparing the grounded answer…" } if (activity.phase === "SKILL_RESOURCE") { if (activity.state === "ACTIVE") return "Reading a skill reference…" if (activity.state === "FAILED") return "Skill reference unavailable — continuing…" - return "Skill reference ready" + return "Preparing the grounded answer…" } return "Preparing the grounded answer…" } + +export interface AssistantSkillReceipt { + ordinal: number + title: string | null + activation: "ACTIVE" | "COMPLETE" + resource: "ACTIVE" | "COMPLETE" | "FAILED" | null +} + +export function hasVisibleAssistantOutput(message: Pick) { + return message.parts.some( + (part) => part.type === "text" && part.text.trim().length > 0, + ) +} + +export function reduceSkillReceipts( + current: AssistantSkillReceipt[], + activity: AssistantActivity, +): AssistantSkillReceipt[] { + const ordinal = activity.skillOrdinal + if ( + (activity.phase !== "SKILL_ACTIVATION" && activity.phase !== "SKILL_RESOURCE") || + ordinal == null + ) { + return current + } + + if (activity.phase === "SKILL_ACTIVATION") { + if (activity.state === "FAILED") { + return current.filter((receipt) => receipt.ordinal !== ordinal) + } + const existing = current.find((receipt) => receipt.ordinal === ordinal) + const title = activity.state === "COMPLETE" ? activity.skillTitle ?? null : null + const next: AssistantSkillReceipt = { + ordinal, + title: title ?? existing?.title ?? null, + activation: activity.state, + resource: existing?.resource ?? null, + } + return [...current.filter((receipt) => receipt.ordinal !== ordinal), next].sort( + (left, right) => left.ordinal - right.ordinal, + ) + } + + const existing = current.find((receipt) => receipt.ordinal === ordinal) + if (!existing?.title || existing.activation !== "COMPLETE") return current + return current.map((receipt) => + receipt.ordinal === ordinal + ? { ...receipt, resource: activity.state } + : receipt, + ) +} diff --git a/apps/web/src/features/assistant/components/assistant-page.test.ts b/apps/web/src/features/assistant/components/assistant-page.test.ts index f6b2667b..bc187c4b 100644 --- a/apps/web/src/features/assistant/components/assistant-page.test.ts +++ b/apps/web/src/features/assistant/components/assistant-page.test.ts @@ -1,8 +1,31 @@ import { describe, expect, it } from "vitest" -import { activityLabel } from "@/features/assistant/assistant-activity" +import { + activityLabel, + hasVisibleAssistantOutput, + reduceSkillReceipts, +} from "@/features/assistant/assistant-activity" describe("assistant activity labels", () => { + it("does not treat an unrendered source frame as visible answer output", () => { + expect( + hasVisibleAssistantOutput({ + parts: [ + { + type: "source-url", + sourceId: "source-1", + url: "/api/citations/43000000-0000-0000-0000-000000000003/content", + }, + ], + }), + ).toBe(false) + expect( + hasVisibleAssistantOutput({ + parts: [{ type: "text", text: "Answer" }], + }), + ).toBe(true) + }) + it("describes progressive Skill disclosure without exposing tool payloads", () => { expect( activityLabel({ phase: "SKILL_DISCOVERY", state: "ACTIVE" }), @@ -16,9 +39,72 @@ describe("assistant activity labels", () => { ).toBe("Found 2 available skills") expect( activityLabel({ phase: "SKILL_ACTIVATION", state: "COMPLETE" }), - ).toBe("Skill instructions ready") + ).toBe("Preparing the grounded answer…") expect( activityLabel({ phase: "SKILL_RESOURCE", state: "FAILED" }), ).toBe("Skill reference unavailable — continuing…") }) + + it("creates receipts only from named successful activations", () => { + const active = reduceSkillReceipts([], { + phase: "SKILL_ACTIVATION", + state: "ACTIVE", + skillOrdinal: 1, + }) + expect(active).toEqual([ + { ordinal: 1, title: null, activation: "ACTIVE", resource: null }, + ]) + + const completed = reduceSkillReceipts(active, { + phase: "SKILL_ACTIVATION", + state: "COMPLETE", + skillOrdinal: 1, + skillTitle: "Incident response", + }) + expect(completed).toEqual([ + { + ordinal: 1, + title: "Incident response", + activation: "COMPLETE", + resource: null, + }, + ]) + expect( + reduceSkillReceipts(completed, { + phase: "SKILL_RESOURCE", + state: "ACTIVE", + skillOrdinal: 1, + }), + ).toEqual([ + { + ordinal: 1, + title: "Incident response", + activation: "COMPLETE", + resource: "ACTIVE", + }, + ]) + }) + + it("does not infer a receipt from discovery, failures, or lossy resource events", () => { + expect( + reduceSkillReceipts([], { + phase: "SKILL_DISCOVERY", + state: "COMPLETE", + evidenceCount: 2, + }), + ).toEqual([]) + expect( + reduceSkillReceipts([], { + phase: "SKILL_RESOURCE", + state: "COMPLETE", + skillOrdinal: 7, + }), + ).toEqual([]) + expect( + reduceSkillReceipts( + [{ ordinal: 3, title: null, activation: "ACTIVE", resource: null }], + { phase: "SKILL_ACTIVATION", state: "FAILED", skillOrdinal: 3 }, + ), + ).toEqual([]) + }) }) diff --git a/apps/web/src/features/assistant/components/assistant-page.tsx b/apps/web/src/features/assistant/components/assistant-page.tsx index 32c36622..0b979606 100644 --- a/apps/web/src/features/assistant/components/assistant-page.tsx +++ b/apps/web/src/features/assistant/components/assistant-page.tsx @@ -11,7 +11,7 @@ import { ThumbsDown, ThumbsUp, } from "lucide-react" -import type { ReactNode, RefObject } from "react" +import { Fragment, type ReactNode, type RefObject } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" @@ -54,9 +54,13 @@ import { InputGroupButton } from "@/components/ui/input-group" import { createAssistantTransport } from "@/features/assistant/api/chat-transport" import { activityLabel, + hasVisibleAssistantOutput, + reduceSkillReceipts, type AssistantActivity, + type AssistantSkillReceipt, } from "@/features/assistant/assistant-activity" import { AssistantAnswer } from "@/features/assistant/components/assistant-answer" +import { AssistantSkillActivity } from "@/features/assistant/components/assistant-skill-activity" import { AssistantThinkingIndicator } from "@/features/assistant/components/assistant-thinking-indicator" import { type AssistantSourceRef, @@ -137,8 +141,13 @@ function citedSourcesFor(content: string, sources: AssistantSourceRef[]) { return cited } -function hasVisibleOutput(message: UIMessage) { - return textFor(message).trim().length > 0 || sourcesFor(message).length > 0 +function containsControlCharacter(value: string) { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0 + return codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + /\p{Cf}/u.test(character) + }) } function citationNumberFor(source: SourceUrlUIPart) { @@ -220,6 +229,25 @@ function hydratedSources(citations: AssistantCitationResponse[] | undefined) { function isAssistantActivity(value: unknown): value is AssistantActivity { if (!value || typeof value !== "object") return false const activity = value as Record + const validOrdinal = + activity.skillOrdinal === undefined || + activity.skillOrdinal === null || + (typeof activity.skillOrdinal === "number" && + Number.isSafeInteger(activity.skillOrdinal) && + activity.skillOrdinal > 0) + const validTitle = + activity.skillTitle === undefined || + activity.skillTitle === null || + (typeof activity.skillTitle === "string" && + activity.skillTitle.length > 0 && + Array.from(activity.skillTitle).length <= 80 && + !containsControlCharacter(activity.skillTitle)) + const validIdentity = + activity.skillTitle === undefined || + activity.skillTitle === null || + (activity.phase === "SKILL_ACTIVATION" && + activity.state === "COMPLETE" && + typeof activity.skillOrdinal === "number") return ( (activity.phase === "RETRIEVAL" || activity.phase === "GENERATION" || @@ -233,7 +261,10 @@ function isAssistantActivity(value: unknown): value is AssistantActivity { activity.evidenceCount === null || (typeof activity.evidenceCount === "number" && Number.isSafeInteger(activity.evidenceCount) && - activity.evidenceCount >= 0)) + activity.evidenceCount >= 0)) && + validOrdinal && + validTitle && + validIdentity ) } @@ -477,6 +508,10 @@ export function AssistantPage({ } | null>(null) const [previewSource, setPreviewSource] = useState(null) const [activity, setActivity] = useState(null) + const [skillReceipts, setSkillReceipts] = useState([]) + const [awaitingVisibleAnswer, setAwaitingVisibleAnswer] = useState(false) + const [finishedWithoutAnswer, setFinishedWithoutAnswer] = useState(false) + const activityAcceptingRef = useRef(false) const submitLock = useRef(false) const { messages, @@ -489,11 +524,26 @@ export function AssistantPage({ } = useChat({ transport, onData: (part) => { - if (part.type !== "data-assistantActivity" || !isAssistantActivity(part.data)) return - setActivity(part.data) + if ( + !activityAcceptingRef.current || + part.type !== "data-assistantActivity" || + !isAssistantActivity(part.data) + ) return + const nextActivity = part.data + setActivity(nextActivity) + setSkillReceipts((current) => reduceSkillReceipts(current, nextActivity)) }, - onFinish: () => { - setActivity(null) + onFinish: ({ message, isAbort, isError }) => { + activityAcceptingRef.current = false + if (isAbort || isError) { + setActivity(null) + setAwaitingVisibleAnswer(false) + setSkillReceipts([]) + } else if (!hasVisibleAssistantOutput(message)) { + setActivity(null) + setAwaitingVisibleAnswer(false) + setFinishedWithoutAnswer(true) + } const invalidations = [ queryClient.invalidateQueries({ queryKey: conversationListQueryKey, @@ -516,9 +566,19 @@ export function AssistantPage({ } void Promise.all(invalidations) }, + onError: () => { + activityAcceptingRef.current = false + setActivity(null) + setAwaitingVisibleAnswer(false) + setSkillReceipts([]) + }, }) const stop = useCallback(() => { + activityAcceptingRef.current = false setActivity(null) + setAwaitingVisibleAnswer(false) + setFinishedWithoutAnswer(false) + setSkillReceipts([]) stopChat() }, [stopChat]) const historyOptions = getAssistantConversationHistoryOptions({ @@ -583,6 +643,9 @@ export function AssistantPage({ setPreviewSource(null) setFeedbackByMessage({}) setActivity(null) + setSkillReceipts([]) + setAwaitingVisibleAnswer(false) + setFinishedWithoutAnswer(false) setMessages([]) }, [actorKey, conversationId, setMessages, stop]) @@ -621,15 +684,22 @@ export function AssistantPage({ }, [conversationId, history.data, setMessages]) const busy = status === "submitted" || status === "streaming" const latestMessage = messages.at(-1) + const currentAssistant = latestMessage?.role === "assistant" ? latestMessage : undefined + const currentAssistantVisible = currentAssistant + ? hasVisibleAssistantOutput(currentAssistant) + : false + + useEffect(() => { + if (!awaitingVisibleAnswer || !currentAssistantVisible) return + setAwaitingVisibleAnswer(false) + setActivity(null) + }, [awaitingVisibleAnswer, currentAssistantVisible]) + const retryText = [...messages] .reverse() .find((message) => message.role === "user") const retryMessage = retryText ? textFor(retryText) : "" - const showWaiting = - busy && - (latestMessage === undefined || - latestMessage.role === "user" || - !hasVisibleOutput(latestMessage)) + const showWaiting = awaitingVisibleAnswer && !currentAssistantVisible const showThinking = showWaiting const openSources = useCallback( ( @@ -662,7 +732,11 @@ export function AssistantPage({ nextTitleRef.current = message.length <= 80 ? message : `${message.slice(0, 77)}...` clearError() + activityAcceptingRef.current = true setActivity(null) + setSkillReceipts([]) + setFinishedWithoutAnswer(false) + setAwaitingVisibleAnswer(true) const turn = sendMessage({ text: message }) if (clearComposer) clearDraft() const release = () => { @@ -813,7 +887,14 @@ export function AssistantPage({ {messages.map((message, index) => ( - + + {currentAssistant?.id === message.id ? ( + + ) : null} + {({ sources, unavailable, anchorRef }) => { const content = textFor(message) const citedSources = citedSourcesFor(content, sources) @@ -925,8 +1006,12 @@ export function AssistantPage({ ) }} - + + ))} + {!currentAssistant ? ( + + ) : null} {showThinking ? ( @@ -934,13 +1019,15 @@ export function AssistantPage({ ) : null} - {error ? ( + {error || finishedWithoutAnswer ? (

- OrgMemory could not complete this turn. + {finishedWithoutAnswer + ? "OrgMemory completed the turn without an answer." + : "OrgMemory could not complete this turn."}