mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
Preserve Assistant retry request context
This commit is contained in:
@@ -99,6 +99,11 @@ runtime cost control, and shared AI transport surfaces.
|
||||
replay one-shot finding handoff, approval, autonomous-mode, or other scoped
|
||||
send options. Scoped context replay remains owned by explicit session
|
||||
handoff metadata or queued follow-up edit state.
|
||||
Failed-turn retry is part of that same local chat-runtime boundary: a
|
||||
retryable in-memory assistant error may replay the original user turn's
|
||||
structured mentions, finding id, approval override, handoff resources,
|
||||
handoff actions, and handoff metadata, but must not reconstruct scoped
|
||||
context from prompt history or saved transcript prose.
|
||||
Assistant output hygiene is part of the same boundary: provider reasoning
|
||||
and raw serialized tool-call artifacts must never render as assistant
|
||||
transcript prose. Reasoning/thinking deltas may update neutral progress
|
||||
|
||||
@@ -265,6 +265,72 @@ describe('useChat', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('retryMessage preserves mentions, finding handoff, and scoped send options', async () => {
|
||||
mockChat.mockRejectedValueOnce(new Error('server error')).mockResolvedValueOnce(undefined);
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 'sess' }));
|
||||
|
||||
await chat.sendMessage(
|
||||
'check this resource',
|
||||
[{ id: 'vm-100', name: 'web-1', type: 'vm', node: 'pve-1' }],
|
||||
'finding-42',
|
||||
{
|
||||
autonomousMode: false,
|
||||
handoffContext: '[Patrol Finding Context]\nFinding ID: finding-42',
|
||||
handoffResources: [
|
||||
{
|
||||
id: 'vm-100',
|
||||
name: 'web-1',
|
||||
type: 'vm',
|
||||
node: 'pve-1',
|
||||
},
|
||||
],
|
||||
handoffActions: [
|
||||
{
|
||||
findingId: 'finding-42',
|
||||
approvalId: 'approval-1',
|
||||
approvalStatus: 'pending',
|
||||
},
|
||||
],
|
||||
handoffMetadata: {
|
||||
kind: 'patrol_finding',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const failed = chat.messages().find((m) => m.role === 'assistant');
|
||||
expect(failed?.error).toContain('server error');
|
||||
|
||||
chat.retryMessage(failed!.id);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockChat).toHaveBeenCalledTimes(2);
|
||||
const retryCall = mockChat.mock.calls[1];
|
||||
expect(retryCall[0]).toBe('check this resource');
|
||||
expect(retryCall[5]).toEqual([{ id: 'vm-100', name: 'web-1', type: 'vm', node: 'pve-1' }]);
|
||||
expect(retryCall[6]).toBe('finding-42');
|
||||
expect(retryCall[7]).toBe(false);
|
||||
expect(retryCall[8]).toBe('[Patrol Finding Context]\nFinding ID: finding-42');
|
||||
expect(retryCall[9]).toEqual([
|
||||
{
|
||||
id: 'vm-100',
|
||||
name: 'web-1',
|
||||
type: 'vm',
|
||||
node: 'pve-1',
|
||||
},
|
||||
]);
|
||||
expect(retryCall[10]).toEqual([
|
||||
{
|
||||
findingId: 'finding-42',
|
||||
approvalId: 'approval-1',
|
||||
approvalStatus: 'pending',
|
||||
},
|
||||
]);
|
||||
expect(retryCall[11]).toEqual({
|
||||
kind: 'patrol_finding',
|
||||
});
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('handles AbortError silently (returns false, no notification)', async () => {
|
||||
const abortError = new Error('Aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PendingQuestion,
|
||||
PendingTool,
|
||||
WorkflowStatus,
|
||||
ChatMessageRequestContext,
|
||||
} from '../types';
|
||||
|
||||
const generateId = () => Math.random().toString(36).substring(2, 9);
|
||||
@@ -257,6 +258,80 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return typeof id === 'string' ? id.trim() : '';
|
||||
};
|
||||
|
||||
const cloneMentions = (mentions?: ChatMention[]): ChatMention[] | undefined =>
|
||||
mentions?.map((mention) => ({ ...mention }));
|
||||
|
||||
const cloneSendOptions = (
|
||||
sendOptions?: SendMessageOptions,
|
||||
): ChatMessageRequestContext | undefined => {
|
||||
if (!sendOptions) return undefined;
|
||||
|
||||
const requestContext: ChatMessageRequestContext = {};
|
||||
if (typeof sendOptions.autonomousMode === 'boolean') {
|
||||
requestContext.autonomousMode = sendOptions.autonomousMode;
|
||||
}
|
||||
if (sendOptions.handoffContext) {
|
||||
requestContext.handoffContext = sendOptions.handoffContext;
|
||||
}
|
||||
if (sendOptions.handoffResources?.length) {
|
||||
requestContext.handoffResources = sendOptions.handoffResources.map((resource) => ({
|
||||
...resource,
|
||||
}));
|
||||
}
|
||||
if (sendOptions.handoffActions?.length) {
|
||||
requestContext.handoffActions = sendOptions.handoffActions.map((action) => ({ ...action }));
|
||||
}
|
||||
if (sendOptions.handoffMetadata) {
|
||||
requestContext.handoffMetadata = { ...sendOptions.handoffMetadata };
|
||||
}
|
||||
|
||||
return Object.keys(requestContext).length > 0 ? requestContext : undefined;
|
||||
};
|
||||
|
||||
const buildRequestContext = (
|
||||
mentions?: ChatMention[],
|
||||
findingId?: string,
|
||||
sendOptions?: SendMessageOptions,
|
||||
): ChatMessageRequestContext | undefined => {
|
||||
const requestContext: ChatMessageRequestContext = {
|
||||
...(cloneSendOptions(sendOptions) || {}),
|
||||
};
|
||||
const clonedMentions = cloneMentions(mentions);
|
||||
if (clonedMentions?.length) {
|
||||
requestContext.mentions = clonedMentions;
|
||||
}
|
||||
if (findingId) {
|
||||
requestContext.findingId = findingId;
|
||||
}
|
||||
|
||||
return Object.keys(requestContext).length > 0 ? requestContext : undefined;
|
||||
};
|
||||
|
||||
const sendOptionsFromRequestContext = (
|
||||
request?: ChatMessageRequestContext,
|
||||
): SendMessageOptions | undefined => {
|
||||
if (!request) return undefined;
|
||||
|
||||
const sendOptions: SendMessageOptions = {};
|
||||
if (typeof request.autonomousMode === 'boolean') {
|
||||
sendOptions.autonomousMode = request.autonomousMode;
|
||||
}
|
||||
if (request.handoffContext) {
|
||||
sendOptions.handoffContext = request.handoffContext;
|
||||
}
|
||||
if (request.handoffResources?.length) {
|
||||
sendOptions.handoffResources = request.handoffResources.map((resource) => ({ ...resource }));
|
||||
}
|
||||
if (request.handoffActions?.length) {
|
||||
sendOptions.handoffActions = request.handoffActions.map((action) => ({ ...action }));
|
||||
}
|
||||
if (request.handoffMetadata) {
|
||||
sendOptions.handoffMetadata = { ...request.handoffMetadata };
|
||||
}
|
||||
|
||||
return Object.keys(sendOptions).length > 0 ? sendOptions : undefined;
|
||||
};
|
||||
|
||||
const applyStreamSessionId = (streamSessionId: string) => {
|
||||
if (!streamSessionId) return;
|
||||
const previousSessionId = sessionId();
|
||||
@@ -633,6 +708,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
content: trimmedPrompt,
|
||||
timestamp,
|
||||
delivery: 'queued',
|
||||
request: buildRequestContext(mentions, findingId, sendOptions),
|
||||
};
|
||||
|
||||
const queuedFollowUp: QueuedFollowUp = {
|
||||
@@ -674,6 +750,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
role: 'user',
|
||||
content: trimmedPrompt,
|
||||
timestamp: new Date(),
|
||||
request: buildRequestContext(mentions, findingId, sendOptions),
|
||||
};
|
||||
|
||||
const assistantId = generateId();
|
||||
@@ -1026,9 +1103,15 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (userIdx < 0) return;
|
||||
const prompt = msgs[userIdx].content;
|
||||
if (!prompt.trim()) return;
|
||||
const request = msgs[userIdx].request;
|
||||
const removeIds = new Set([msgs[userIdx].id, assistantMessageId]);
|
||||
setMessages((prev) => prev.filter((m) => !removeIds.has(m.id)));
|
||||
void sendMessage(prompt);
|
||||
void sendMessage(
|
||||
prompt,
|
||||
cloneMentions(request?.mentions),
|
||||
request?.findingId,
|
||||
sendOptionsFromRequestContext(request),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// Chat component types
|
||||
import type {
|
||||
ChatHandoffAction,
|
||||
ChatHandoffMetadata,
|
||||
ChatHandoffResource,
|
||||
ChatMention,
|
||||
} from '@/api/aiChat';
|
||||
|
||||
export interface ToolExecution {
|
||||
name: string;
|
||||
@@ -109,11 +115,22 @@ export interface WorkflowStatus {
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageRequestContext {
|
||||
mentions?: ChatMention[];
|
||||
findingId?: string;
|
||||
autonomousMode?: boolean;
|
||||
handoffContext?: string;
|
||||
handoffResources?: ChatHandoffResource[];
|
||||
handoffActions?: ChatHandoffAction[];
|
||||
handoffMetadata?: ChatHandoffMetadata;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
delivery?: 'sent' | 'queued';
|
||||
request?: ChatMessageRequestContext;
|
||||
interruption?: 'stopped' | 'replaced';
|
||||
// Clean, user-facing error for a failed turn. Rendered as a distinct error
|
||||
// block (not as answer content) so partial streamed content is preserved and
|
||||
|
||||
Reference in New Issue
Block a user