Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .tegami/2026-08-06-assistant-skill-activity-receipt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
packages:
orgmemory: patch
subject: Show Assistant Skill activity without a blank wait
---

## Improvements

The Assistant now keeps its progress state visible until answer text appears
and shows a compact, current-turn receipt when it successfully activates a
governed Skill. Skill titles are bounded plain text, denied or failed Skills
remain unnamed, and the receipt clears safely when a turn ends without an
answer.
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,7 @@ private static Map<String, Object> 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(
Expand All @@ -121,6 +118,20 @@ private static Map<String, Object> payload(AssistantStreamPart part) {
};
}

private static Map<String, Object> activityFields(AssistantStreamPart.Activity activity) {
Map<String, Object> 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<String> event(String data) {
return ServerSentEvent.builder(data).build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
59 changes: 57 additions & 2 deletions apps/web/src/features/assistant/assistant-activity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { UIMessage } from "ai"

export interface AssistantActivity {
phase:
| "RETRIEVAL"
Expand All @@ -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) {
Expand All @@ -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<UIMessage, "parts">) {
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,
)
}
90 changes: 88 additions & 2 deletions apps/web/src/features/assistant/components/assistant-page.test.ts
Original file line number Diff line number Diff line change
@@ -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" }),
Expand All @@ -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([])
})
})
Loading