Skip to content
Closed
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
54 changes: 44 additions & 10 deletions apps/desktop/src/main/pi-chat-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1772,12 +1772,18 @@ export function registerPiChatHandlers({
serviceOverride?: string,
attachments?: PreparedChatAttachment[],
peerOverride?: string,
): Promise<{ ok: boolean; error?: string; stopReason?: ChatStreamStopReason }> => {
options?: { branchBeforeLastUserMessage?: boolean },
): Promise<{ ok: boolean; error?: string; stopReason?: ChatStreamStopReason; editBranchPrepared?: boolean }> => {
const trimmedMessage = userMessage.trim();
const includeEditBranchState = Boolean(options?.branchBeforeLastUserMessage);
let editBranchPrepared = false;
const withEditBranchState = <T extends { ok: boolean }>(result: T): T & { editBranchPrepared?: boolean } => (
includeEditBranchState ? { ...result, editBranchPrepared } : result
);
const attachmentPromptText = buildAttachmentPromptText(attachments);
const attachmentImages = extractAttachmentImages(attachments);
if (trimmedMessage.length === 0 && attachmentPromptText.length === 0 && attachmentImages.length === 0) {
return { ok: false, error: 'Empty message' };
return withEditBranchState({ ok: false, error: 'Empty message' });
}

const existingRun = activeRunsByConversation.get(conversationId);
Expand Down Expand Up @@ -1810,15 +1816,31 @@ export function registerPiChatHandlers({
}
}
if (!proxyAvailable) {
return {
return withEditBranchState({
ok: false,
error: `Buyer proxy is not reachable on port ${proxyPort}. Start Buyer runtime or fix buyer.proxyPort in config.`,
};
});
}

const sessionManager = await store.openSessionManager(conversationId);
if (!sessionManager) {
return { ok: false, error: 'Conversation not found' };
return withEditBranchState({ ok: false, error: 'Conversation not found' });
}

if (options?.branchBeforeLastUserMessage) {
const branch = sessionManager.getBranch();
const lastUserEntry = [...branch]
.reverse()
.find((entry) => entry.type === 'message' && entry.message?.role === 'user');
if (!lastUserEntry) {
return withEditBranchState({ ok: false, error: 'No user message found to edit' });
}
if (lastUserEntry.parentId) {
sessionManager.branch(lastUserEntry.parentId);
} else {
sessionManager.resetLeaf();
}
editBranchPrepared = true;
}

const context = sessionManager.buildSessionContext();
Expand Down Expand Up @@ -2280,7 +2302,7 @@ export function registerPiChatHandlers({
}
pendingAssistantMessage = null;
const reason = emitPaymentRequiredStreamError(conversationId, amt);
return { ok: false, error: 'Payment required', stopReason: reason };
return withEditBranchState({ ok: false, error: 'Payment required', stopReason: reason });
}
}

Expand Down Expand Up @@ -2315,7 +2337,7 @@ export function registerPiChatHandlers({
appendSystemLog(`Conversation title generation failed: ${asErrorMessage(error)}`);
}
}
return { ok: true };
return withEditBranchState({ ok: true });
} catch (error) {
// Always discard any buffered assistant message on error — it will not be committed.
pendingAssistantMessage = null;
Expand All @@ -2330,7 +2352,7 @@ export function registerPiChatHandlers({
error: 'Request aborted',
stopReason: reason,
});
return { ok: false, error: 'Aborted', stopReason: reason };
return withEditBranchState({ ok: false, error: 'Aborted', stopReason: reason });
}
const message = asErrorMessage(error);
// Map insufficient balance / 402 errors to payment_required format
Expand All @@ -2356,7 +2378,7 @@ export function registerPiChatHandlers({
});
}
const reason = emitPaymentRequiredStreamError(conversationId, amt);
return { ok: false, error: message, stopReason: reason };
return withEditBranchState({ ok: false, error: message, stopReason: reason });
} else {
const reason = classifyChatStreamFailure({
error,
Expand All @@ -2369,7 +2391,7 @@ export function registerPiChatHandlers({
stopReason: reason,
});
appendSystemLog(`Pi chat error: ${formatChatStreamStopForLog(reason)}`);
return { ok: false, error: message, stopReason: reason };
return withEditBranchState({ ok: false, error: message, stopReason: reason });
}
} finally {
clearActiveRun(run);
Expand Down Expand Up @@ -2724,6 +2746,18 @@ export function registerPiChatHandlers({
},
);

ipcMain.handle(
'chat:ai-edit-last-user-message',
async (_event, conversationId: string, userMessage: string, service?: string, _provider?: string, attachments?: PreparedChatAttachment[], peerId?: string) => {
// `_provider` is accepted for IPC ABI compatibility with normal sends
// but ignored — the buyer proxy resolves the route plan from the pinned
// peer + service ID without a provider hint.
return await runStreamingPrompt(conversationId, userMessage, service, attachments, peerId, {
branchBeforeLastUserMessage: true,
});
},
);

ipcMain.handle('chat:ai-abort', async (_event, conversationId?: string) => {
const trimmedConversationId = typeof conversationId === 'string' ? conversationId.trim() : '';
const activeRuns = trimmedConversationId
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ type ChatAiStreamStopReason = {
errorCode?: string;
};

type ChatAiSendResult = {
ok: boolean;
error?: string;
stopReason?: ChatAiStreamStopReason;
editBranchPrepared?: boolean;
};

const api = {
// Synchronous platform info from the Node side of the preload. Renderer
// code can use this without a round-trip to the main process — useful for
Expand Down Expand Up @@ -233,9 +240,12 @@ const api = {
chatAiSend(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<{ ok: boolean; error?: string }> {
return ipcRenderer.invoke('chat:ai-send', conversationId, message, service, provider, attachments, peerId);
},
chatAiSendStream(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<{ ok: boolean; error?: string; stopReason?: ChatAiStreamStopReason }> {
chatAiSendStream(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<ChatAiSendResult> {
return ipcRenderer.invoke('chat:ai-send-stream', conversationId, message, service, provider, attachments, peerId);
},
chatAiEditLastUserMessage(conversationId: string, message: string, service?: string, provider?: string, attachments?: PreparedChatAttachment[], peerId?: string): Promise<ChatAiSendResult> {
return ipcRenderer.invoke('chat:ai-edit-last-user-message', conversationId, message, service, provider, attachments, peerId);
},
chatAiAbort(conversationId?: string): Promise<{ ok: boolean }> {
return ipcRenderer.invoke('chat:ai-abort', conversationId);
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ registerActions({
openConversation: chatApi.openConversation,
sendMessage: chatApi.sendMessage,
sendMessageToConversation: chatApi.sendMessageToConversation,
editLastUserMessage: chatApi.editLastUserMessage,
abortChat: chatApi.abortChat,
deleteConversation: chatApi.deleteConversation,
renameConversation: chatApi.renameConversation,
Expand Down
138 changes: 138 additions & 0 deletions apps/desktop/src/renderer/modules/chat.peer-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,3 +937,141 @@ test('switching service mid-conversation routes the next send to the new model',
}
await waitFor(() => uiState.chatSendingConversationIds.length === 0);
});

test('edit regenerate retries a stuck in-flight request without branching twice', async () => {
installDomTimers();

const uiState = createInitialUiState();
uiState.chatActiveConversation = 'conv-edit';
uiState.chatConversations = [{
id: 'conv-edit',
title: 'Edit',
service: 'model-a',
provider: 'openai',
peerId: 'peer-a',
messageCount: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
usage: { inputTokens: 0, outputTokens: 0 },
totalTokens: 0,
totalEstimatedCostUsd: 0,
}];
uiState.chatMessages = [{ role: 'user', content: 'original', createdAt: 1 }];

let editCalls = 0;
let streamCalls = 0;
let abortCalls = 0;
const bridge: DesktopBridge = {
chatAiEditLastUserMessage: async () => {
editCalls += 1;
if (editCalls === 1) {
return { ok: false, error: 'Request already in progress', editBranchPrepared: false };
}
return { ok: true, editBranchPrepared: true };
},
chatAiSendStream: async () => {
streamCalls += 1;
return { ok: true };
},
chatAiAbort: async () => {
abortCalls += 1;
return { ok: true };
},
};

const api = initChatModule({
bridge,
uiState,
appendSystemLog: () => undefined,
});

api.editLastUserMessage('conv-edit', 'edited');
await waitFor(() => editCalls === 2);

assert.equal(abortCalls, 1);
assert.equal(editCalls, 2);
assert.equal(streamCalls, 0);
});

test('manual payment retry after edit regenerate reuses edit retry context', async () => {
installDomTimers();

const uiState = createInitialUiState();
uiState.chatActiveConversation = 'conv-pay-edit';
uiState.chatConversations = [{
id: 'conv-pay-edit',
title: 'Payment edit',
service: 'model-a',
provider: 'openai',
peerId: 'peer-a',
messageCount: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
usage: { inputTokens: 0, outputTokens: 0 },
totalTokens: 0,
totalEstimatedCostUsd: 0,
}];

const attachment = {
id: 'att-1',
attachmentId: 'disk-1',
name: 'note.txt',
mimeType: 'text/plain',
size: 4,
kind: 'text' as const,
status: 'ready' as const,
text: 'note',
};
uiState.chatMessages = [{
role: 'user',
content: [
{ type: 'text', text: 'original' },
{ type: 'file', fileName: 'note.txt', mimeType: 'text/plain', attachment },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'render-only' } },
],
createdAt: 1,
}];

let editCalls = 0;
const streamSends: Array<{ message: string; attachmentIds: string[] }> = [];
const bridge: DesktopBridge = {
creditsGetInfo: async () => ({
ok: true,
data: {
evmAddress: null,
operatorAddress: null,
balanceUsdc: '0',
reservedUsdc: '0',
availableUsdc: '0',
creditLimitUsdc: '0',
},
error: null,
}),
chatAiEditLastUserMessage: async () => {
editCalls += 1;
return { ok: false, error: 'payment_required:1000000', editBranchPrepared: true };
},
chatAiSendStream: async (_conversationId, message, _service, _provider, attachments) => {
streamSends.push({
message,
attachmentIds: (attachments ?? []).map((item) => item.id),
});
return { ok: true };
},
};

const api = initChatModule({
bridge,
uiState,
appendSystemLog: () => undefined,
});

api.editLastUserMessage('conv-pay-edit', 'edited');
await waitFor(() => uiState.chatPaymentApprovalVisible);

api.retryAfterPayment();
await waitFor(() => streamSends.length === 1);

assert.equal(editCalls, 1);
assert.deepEqual(streamSends, [{ message: 'edited', attachmentIds: ['att-1'] }]);
});
Loading
Loading