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
50 changes: 47 additions & 3 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,10 +700,12 @@ export type ConversationContext =
| { kind: "manager" }
| { kind: "goal"; goalId: string }
| { kind: "project"; projectRef: string }
| ({ kind: "managed" } & ManagedConversationTarget)
// A UI address for an existing source-owned Session, never a new Core channel.
| ({ kind: "bound" } & BoundConversationTarget);

export type BoundConversationTarget = { bindingId: string; sessionId: string };
export type ManagedConversationTarget = {goalId: string; agentId: string; sessionId: string};

/** Goal projections cannot answer a source-owned or ordinary workspace Chat. */
export function conversationSupportsStatusProjection(context: ConversationContext) {
Expand All @@ -715,12 +717,17 @@ const PROJECT_CONVERSATION_KEY_PREFIX = "project/";

export function conversationContextKey(context: ConversationContext): string {
if (context.kind === "manager") return "manager";
if (context.kind === "managed") return `managed/${encodeURIComponent(context.goalId)}/${encodeURIComponent(context.agentId)}/${encodeURIComponent(context.sessionId)}`;
if (context.kind === "bound") return `bound/${encodeURIComponent(context.bindingId)}/${encodeURIComponent(context.sessionId)}`;
return context.kind === "goal" ? context.goalId : `${PROJECT_CONVERSATION_KEY_PREFIX}${context.projectRef}`;
}

export function conversationContextOfKey(key: string): ConversationContext {
if (key === "manager") return { kind: "manager" };
if (key.startsWith("managed/")) {
const [, goalId = "", agentId = "", sessionId = ""] = key.split("/");
return {kind: "managed", goalId: decodeURIComponent(goalId), agentId: decodeURIComponent(agentId), sessionId: decodeURIComponent(sessionId)};
}
if (key.startsWith("bound/")) {
const [, bindingId = "", sessionId = ""] = key.split("/");
return { kind: "bound", bindingId: decodeURIComponent(bindingId), sessionId: decodeURIComponent(sessionId) };
Expand All @@ -733,6 +740,7 @@ export function conversationContextOfKey(key: string): ConversationContext {

export function conversationChannelId(context: ConversationContext): string {
if (context.kind === "manager") return "manager";
if (context.kind === "managed") return `agent.${context.agentId}`;
if (context.kind === "bound") return conversationContextKey(context);
return context.kind === "goal" ? `goal.${context.goalId}` : `project.${context.projectRef}`;
}
Expand All @@ -745,6 +753,14 @@ export async function createChatSession(
agentModelId?: string,
agentReasoningEffort?: string,
) {
if (context.kind === "managed") {
if (mode !== "resume_latest" || agentModelId || agentReasoningEffort) {
throw new Error("An original Agent Session cannot be replaced or reconfigured here.");
}
const session = await resolveManagedChatSession(context, signal);
if (!session.resumable || (agentId && agentId !== session.agent_id)) throw new Error("The original managed Session is unavailable.");
return {ok: true as const, resumed: true, session_id: session.session_id, agent_id: session.agent_id, goal_id: session.goal_id, session};
}
if (context.kind === "bound") {
if (mode !== "resume_latest" || agentModelId || agentReasoningEffort) {
throw new Error("An original conversation cannot be replaced or reconfigured here.");
Expand Down Expand Up @@ -804,6 +820,8 @@ export type ChatSessionSummary = {
goal_id: string | null;
project_ref?: string | null;
agent_id: string;
managed_agent_id?: string;
goal_instance_id?: string | null;
agent_model_id?: string | null;
agent_reasoning_effort?: string | null;
agent_service_tier?: string | null;
Expand Down Expand Up @@ -920,6 +938,26 @@ export async function fetchChatSession(sessionId: string, signal?: AbortSignal)
return requestJson<ChatSessionSnapshot>(`/api/chat/sessions/${encodeURIComponent(sessionId)}`, { signal });
}

/** Read-only discovery through the existing Core Goal/Agent/Session identity. */
export async function fetchManagedAgentSessions(goalId: string, agentId: string, signal?: AbortSignal) {
if (!/^[A-Za-z0-9._-]{1,160}$/.test(goalId) || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(agentId)) throw new Error("Invalid registered Agent address.");
const listed = await requestJson<{ok: true; schema_version: "loopx_chat_session_list_v1"; goal_id: string; managed_agent_id: string; goal_instance_id: string | null; sessions: ChatSessionSummary[]}>(
`/api/chat/sessions?${new URLSearchParams({goal_id: goalId, managed_agent_id: agentId})}`, {signal});
if (listed.ok !== true || listed.schema_version !== "loopx_chat_session_list_v1" || listed.goal_id !== goalId || listed.managed_agent_id !== agentId || !Array.isArray(listed.sessions)
|| !Object.hasOwn(listed, "goal_instance_id") || listed.sessions.some(row => row.goal_id !== goalId || row.managed_agent_id !== agentId
|| row.goal_instance_id !== listed.goal_instance_id || row.channel_id !== `agent.${agentId}` || row.session_mode !== "managed_runtime")) {
throw new Error("This service cannot verify the registered Agent's original Sessions. Update LoopX and retry.");
}
return listed;
}

export async function resolveManagedChatSession(target: ManagedConversationTarget, signal?: AbortSignal) {
const listed = await fetchManagedAgentSessions(target.goalId, target.agentId, signal);
const sessions = listed.sessions.filter(row => row.session_id === target.sessionId);
if (sessions.length !== 1) throw new Error("The original managed Session is no longer available from this Agent.");
return sessions[0];
}

export async function fetchChatSessions(options: {
agentId?: string;
channelId?: string;
Expand Down Expand Up @@ -999,25 +1037,31 @@ export async function fetchChatHistory(options: {
channelId: string;
goalId?: string;
bound?: BoundConversationTarget;
managed?: ManagedConversationTarget;
}, previous?: ChatHistory): Promise<ChatHistory> {
// Source authorization must be re-read even when recovering an older snapshot.
const listed = (!options.bound ? previous : undefined) ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(5000) });
const exact = Boolean(options.bound || options.managed);
const listed = options.managed ? {sessions: [await resolveManagedChatSession(options.managed, AbortSignal.timeout(5000))]}
: (!options.bound ? previous : undefined) ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(5000) });
const known = new Map(previous?.snapshots.map((snapshot) => [snapshot.session.session_id, snapshot]));
// A failed historical read is not an empty transcript. Retrying only the
// missing snapshots keeps this recovery read-only and bounds repeated work.
const missing = listed.sessions.filter((session) => {
const saved = known.get(session.session_id)?.session;
return !saved || (options.bound && (!session.transcript_revision
return !saved || (exact && (!session.transcript_revision
|| session.transcript_revision !== saved.transcript_revision
|| session.updated_at !== saved.updated_at || session.active_turn_id !== saved.active_turn_id
|| session.status !== saved.status));
});
if (options.bound) for (const session of missing) known.delete(session.session_id);
if (exact) for (const session of missing) known.delete(session.session_id);
const results = await Promise.allSettled(missing.map(async (session) => {
const snapshot = await fetchChatSession(session.session_id, AbortSignal.timeout(5000));
if (snapshot.session.session_id !== session.session_id || snapshot.session.agent_id !== session.agent_id) {
throw new Error("The original Session identity changed during history read.");
}
if (options.managed && (snapshot.ok !== true || snapshot.schema_version !== "loopx_chat_store_v1" || snapshot.session.goal_id !== session.goal_id || snapshot.session.managed_agent_id !== session.managed_agent_id
|| snapshot.session.goal_instance_id !== session.goal_instance_id || snapshot.session.channel_id !== session.channel_id
|| snapshot.session.session_mode !== "managed_runtime")) throw new Error("The original managed Session scope changed during history read.");
return snapshot;
}));
for (const result of results) {
Expand Down
32 changes: 18 additions & 14 deletions apps/presentation/dashboard/src/data/use-conversation-history.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchChatHistory, type ChatHistory, type BoundConversationTarget } from "./chat";
import { fetchChatHistory, type ChatHistory, type BoundConversationTarget, type ManagedConversationTarget } from "./chat";

type HistoryState = {
scope: string;
Expand All @@ -16,33 +16,36 @@ export function currentChannelSession(history: Pick<ChatHistory, "sessions"> | n
}

/** Read recovery shared by steward and Goal conversations; never executes a Turn. */
export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled, bound }: {
export function useConversationHistory({ agentId, currentAgentId, channelId, goalId, enabled, bound, managed }: {
agentId?: string;
currentAgentId: string;
channelId: string;
goalId?: string;
enabled: boolean;
bound?: BoundConversationTarget;
managed?: ManagedConversationTarget;
}) {
const bindingId = bound?.bindingId, sessionId = bound?.sessionId;
const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null, bindingId, sessionId]);
const managedGoal = managed?.goalId, managedAgent = managed?.agentId, managedSession = managed?.sessionId;
const exact = Boolean(bound || managed);
const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null, bindingId, sessionId, managedGoal, managedAgent, managedSession]);
const [state, setState] = useState<HistoryState | null>(null);
const retryRead = useRef<(() => void) | null>(null);
const cached = useRef<{ scope: string; history: ChatHistory } | null>(null);
const refreshGeneration = useRef(0);
const cachedHistory = (forScope: string) => cached.current?.scope === forScope ? cached.current.history : null;
const params = useRef({ agentId, channelId, goalId, scope, bound });
params.current = { agentId, channelId, goalId, scope, bound };
const params = useRef({ agentId, channelId, goalId, scope, bound, managed });
params.current = { agentId, channelId, goalId, scope, bound, managed };
// A caller that learned of a Turn the cached read cannot show -- the service
// refused a send because that Turn is running -- needs what the Session looks
// like now, not what this hook read earlier. The re-read stays here so the
// conversation keeps a single history owner.
const refresh = useCallback(async () => {
const { agentId, channelId, goalId, scope, bound } = params.current;
const { agentId, channelId, goalId, scope, bound, managed } = params.current;
const generation = ++refreshGeneration.current;
let loaded: ChatHistory;
try {
loaded = await fetchChatHistory({ agentId, channelId, goalId, bound });
loaded = await fetchChatHistory({ agentId, channelId, goalId, bound, managed });
} catch (error) {
if (params.current.scope === scope && generation === refreshGeneration.current) {
setState({ scope, history: cachedHistory(scope), reading: false, failed: true });
Expand Down Expand Up @@ -80,10 +83,11 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa
const isCurrentRead = () => !cancelled && params.current.scope === scope
&& generation === refreshGeneration.current && cachedHistory(scope) === previous;
setState(current => ({ scope, history: previous, reading: true,
failed: bindingId !== undefined && current?.scope === scope && current.failed }));
failed: exact && current?.scope === scope && current.failed }));
try {
const loaded = await fetchChatHistory({ agentId, channelId, goalId,
bound: bindingId === undefined ? undefined : { bindingId, sessionId: sessionId ?? "" } }, previous ?? undefined);
bound: bindingId === undefined ? undefined : { bindingId, sessionId: sessionId ?? "" },
managed: managedGoal === undefined ? undefined : {goalId: managedGoal, agentId: managedAgent ?? "", sessionId: managedSession ?? ""} }, previous ?? undefined);
if (!isCurrentRead()) return;
cached.current = { scope, history: loaded };
setState({ scope, history: loaded, reading: false, failed: false });
Expand All @@ -93,7 +97,7 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa
} finally {
reading = false;
const known = cachedHistory(scope);
if (!cancelled && (bindingId !== undefined || !known || known.unavailableSessionIds.length > 0)) {
if (!cancelled && (exact || !known || known.unavailableSessionIds.length > 0)) {
// Back off sustained transport failures; a user can retry immediately.
retryTimer = setTimeout(() => void read(), known && !known.unavailableSessionIds.length
? 5000 : Math.min(3000 * 2 ** attempts++, 30_000));
Expand All @@ -108,7 +112,7 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa
clearTimeout(retryTimer);
retryRead.current = null;
};
}, [agentId, channelId, goalId, enabled, scope, bindingId, sessionId]);
}, [agentId, channelId, goalId, enabled, scope, bindingId, sessionId, managedGoal, managedAgent, managedSession, exact]);
const current = enabled && state?.scope === scope ? state : null;
const history = current?.history ?? null;
// A channel transcript spans executors; readability of another executor's
Expand All @@ -124,10 +128,10 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa
: history?.unavailableSessionIds.length ? (history.snapshots.length ? "partial" as const : "unavailable" as const)
: current.reading ? "loading" as const : "ready" as const,
reading: current?.reading ?? enabled,
sendBlocked: enabled && (!currentSessionReadable || (bound !== undefined && current?.failed === true)),
sendBlocked: enabled && (!currentSessionReadable || (exact && current?.failed === true)),
// Missing older history must not restart a recovered current stream.
connectionKey: currentSessionReadable && !(bound && current?.failed)
? `${scope}:${latest?.session_id ?? "empty"}${bound ? `:${latest?.active_turn_id ?? "idle"}` : ""}` : null,
connectionKey: currentSessionReadable && !(exact && current?.failed)
? `${scope}:${latest?.session_id ?? "empty"}${exact ? `:${latest?.active_turn_id ?? "idle"}` : ""}` : null,
retry,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {useEffect, useState} from "react";
import {resolveManagedChatSession, type ChatSessionSummary, type ManagedConversationTarget} from "./chat";

/** Resolve the original Session without opening an adapter or starting work. */
export function useManagedConversation(target: ManagedConversationTarget | null, enabled: boolean) {
const goalId = target?.goalId, agentId = target?.agentId, sessionId = target?.sessionId;
const key = JSON.stringify([goalId, agentId, sessionId]);
const [state, setState] = useState<{key: string; session?: ChatSessionSummary; error?: string}>();
const [revision, setRevision] = useState(0);
useEffect(() => {
if (!enabled || goalId === undefined || agentId === undefined || sessionId === undefined) return;
const controller = new AbortController();
void resolveManagedChatSession({goalId, agentId, sessionId}, AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]))
.then(session => {if (!controller.signal.aborted) setState({key, session});})
.catch(error => {if (!controller.signal.aborted) setState({key, error: error instanceof Error ? error.message : String(error)});});
return () => controller.abort();
}, [goalId, agentId, sessionId, key, enabled, revision]);
return {...(enabled && state?.key === key ? state : {}), retry: () => setRevision(value => value + 1)};
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,10 @@
.goal-agent-work-page form label {flex-basis: 100%;}
.goal-agent-work-page form button[type="submit"] {margin-left: auto;}
}

.agent-work-session-source { display: flex; align-items: center; gap: 8px; margin-block: 8px 16px; color: var(--pw-muted); font-size: 14px; overflow-wrap: anywhere; }

.goal-agent-work-page > .attached-conversation-heading { position: sticky; top: 0; z-index: 2; background: var(--pw-bg); }
.goal-agent-work-page > .attached-conversation-heading .agent-work-navigation { width: 100%; justify-content: space-between; }

.goal-agent-work-page[data-managed] { height: auto; min-height: 100%; }
Loading
Loading