Skip to content

Commit 226f8f1

Browse files
Merge pull request #114 from Marve10s/fix/pending-user-input-stale-advance
Fix empty answers from pending user-input auto-advance
2 parents 29fc3cf + 6f39d2a commit 226f8f1

4 files changed

Lines changed: 102 additions & 27 deletions

File tree

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { TextGeneration } from "../../git/Services/TextGeneration.ts";
4444
import { ProviderService } from "../../provider/Services/ProviderService.ts";
4545
import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts";
4646
import {
47+
buildPriorTranscriptBootstrapText,
4748
buildForkBootstrapText,
4849
buildHandoffBootstrapText,
4950
hasNativeAssistantMessagesBefore,
@@ -793,6 +794,11 @@ const make = Effect.gen(function* () {
793794
if (!thread) {
794795
return;
795796
}
797+
const activeSessionBeforeEnsure = yield* providerService
798+
.listSessions()
799+
.pipe(
800+
Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)),
801+
);
796802
yield* ensureSessionForThread(input.threadId, input.createdAt, {
797803
...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
798804
...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
@@ -825,13 +831,29 @@ const make = Effect.gen(function* () {
825831
shouldBootstrapSidechatContext && availableBootstrapChars > 0
826832
? buildForkBootstrapText(thread, availableBootstrapChars)
827833
: null;
834+
const selectedProvider =
835+
input.modelSelection?.provider ??
836+
threadModelSelections.get(input.threadId)?.provider ??
837+
thread.session?.providerName ??
838+
thread.modelSelection.provider;
839+
const shouldBootstrapPriorTranscriptContext =
840+
(selectedProvider === "kilo" || selectedProvider === "opencode") &&
841+
activeSessionBeforeEnsure === undefined &&
842+
!handoffBootstrapText &&
843+
!sidechatBootstrapText;
844+
const priorTranscriptBootstrapText =
845+
shouldBootstrapPriorTranscriptContext && availableBootstrapChars > 0
846+
? buildPriorTranscriptBootstrapText(thread, input.messageId, availableBootstrapChars)
847+
: null;
828848
const boundaryMessageText = thread.sidechatSourceThreadId
829849
? wrapSidechatInput(input.messageText)
830850
: input.messageText;
831851
const providerInput = handoffBootstrapText
832852
? `<handoff_context>\n${handoffBootstrapText}\n</handoff_context>\n\n<latest_user_message>\n${boundaryMessageText}\n</latest_user_message>`
833853
: sidechatBootstrapText
834854
? `<sidechat_context>\n${sidechatBootstrapText}\n</sidechat_context>\n\n${boundaryMessageText}`
855+
: priorTranscriptBootstrapText
856+
? `<thread_context>\n${priorTranscriptBootstrapText}\n</thread_context>\n\n<latest_user_message>\n${boundaryMessageText}\n</latest_user_message>`
835857
: boundaryMessageText;
836858
const normalizedInput = toNonEmptyProviderInput(providerInput);
837859
const normalizedAttachments = input.attachments ?? [];

apps/server/src/orchestration/handoff.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ export function hasNativeAssistantMessagesBefore(
7373
});
7474
}
7575

76+
export function listPriorTranscriptMessages(
77+
thread: Pick<OrchestrationThread, "messages">,
78+
currentMessageId: string,
79+
): ReadonlyArray<OrchestrationMessage> {
80+
const currentIndex = thread.messages.findIndex((message) => message.id === currentMessageId);
81+
if (currentIndex <= 0) {
82+
return [];
83+
}
84+
85+
return thread.messages.slice(0, currentIndex).filter((message) => {
86+
return (
87+
(message.role === "user" || message.role === "assistant") &&
88+
message.streaming === false &&
89+
normalizeMessageText(message.text).length > 0
90+
);
91+
});
92+
}
93+
7694
function buildImportedMessagesBootstrapText(input: {
7795
thread: Pick<OrchestrationThread, "title" | "branch" | "worktreePath">;
7896
importedMessages: ReadonlyArray<OrchestrationMessage>;
@@ -143,6 +161,25 @@ export function buildHandoffBootstrapText(
143161
});
144162
}
145163

164+
export function buildPriorTranscriptBootstrapText(
165+
thread: Pick<OrchestrationThread, "title" | "branch" | "worktreePath" | "messages">,
166+
currentMessageId: string,
167+
maxChars = HANDOFF_BOOTSTRAP_CHAR_BUDGET,
168+
): string | null {
169+
const priorMessages = listPriorTranscriptMessages(thread, currentMessageId);
170+
if (priorMessages.length === 0) {
171+
return null;
172+
}
173+
174+
return buildImportedMessagesBootstrapText({
175+
thread,
176+
importedMessages: priorMessages,
177+
intro:
178+
"This provider session may have been restarted without native conversation state. Use this prior DP Code transcript as context for the latest user message.",
179+
maxChars,
180+
});
181+
}
182+
146183
export function buildForkBootstrapText(
147184
thread: Pick<OrchestrationThread, "title" | "branch" | "worktreePath" | "messages">,
148185
maxChars = HANDOFF_BOOTSTRAP_CHAR_BUDGET,

apps/server/src/provider/Layers/OpenCodeAdapter.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,9 +1755,18 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
17551755
if (text === undefined) {
17561756
return;
17571757
}
1758-
const previousText = context.emittedTextByPartId.get(part.id);
1758+
const nextTextItemId =
1759+
turnId && part.type === "text" ? openCodeNextTextItemId(turnId) : undefined;
1760+
const itemId =
1761+
nextTextItemId && context.emittedTextByPartId.has(nextTextItemId)
1762+
? nextTextItemId
1763+
: part.id;
1764+
const previousText = context.emittedTextByPartId.get(itemId);
17591765
const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text);
1760-
context.emittedTextByPartId.set(part.id, latestText);
1766+
context.emittedTextByPartId.set(itemId, latestText);
1767+
if (itemId !== part.id) {
1768+
context.emittedTextByPartId.set(part.id, latestText);
1769+
}
17611770
if (latestText !== text) {
17621771
context.partById.set(
17631772
part.id,
@@ -1771,7 +1780,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
17711780
...buildEventBase({
17721781
threadId: context.session.threadId,
17731782
turnId,
1774-
itemId: part.id,
1783+
itemId,
17751784
createdAt:
17761785
part.type === "text" || part.type === "reasoning"
17771786
? isoFromEpochMs(part.time?.start)
@@ -1789,9 +1798,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
17891798
if (
17901799
part.type === "text" &&
17911800
part.time?.end !== undefined &&
1792-
!context.completedAssistantPartIds.has(part.id)
1801+
!context.completedAssistantPartIds.has(itemId)
17931802
) {
1794-
context.completedAssistantPartIds.add(part.id);
1803+
context.completedAssistantPartIds.add(itemId);
1804+
if (itemId !== part.id) {
1805+
context.completedAssistantPartIds.add(part.id);
1806+
}
17951807
const proposedPlanMarkdown =
17961808
context.activeInteractionMode === "plan"
17971809
? extractProposedPlanMarkdown(latestText)
@@ -1801,7 +1813,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
18011813
...buildEventBase({
18021814
threadId: context.session.threadId,
18031815
turnId,
1804-
itemId: part.id,
1816+
itemId,
18051817
createdAt: isoFromEpochMs(part.time.end),
18061818
raw,
18071819
}),
@@ -1815,7 +1827,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
18151827
...buildEventBase({
18161828
threadId: context.session.threadId,
18171829
turnId,
1818-
itemId: part.id,
1830+
itemId,
18191831
createdAt: isoFromEpochMs(part.time.end),
18201832
raw,
18211833
}),

apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type ApprovalRequestId } from "@t3tools/contracts";
2-
import { memo, useCallback, useEffect, useRef } from "react";
2+
import { memo, useEffect, useEffectEvent, useRef } from "react";
33
import { type PendingUserInput } from "../../session-logic";
44
import {
55
derivePendingUserInputProgress,
@@ -61,32 +61,36 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6161
const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
6262
const activeQuestion = progress.activeQuestion;
6363
const autoAdvanceTimerRef = useRef<number | null>(null);
64+
const onAdvanceRef = useRef(onAdvance);
65+
useEffect(() => {
66+
onAdvanceRef.current = onAdvance;
67+
}, [onAdvance]);
6468

65-
// Clear auto-advance timer on unmount
69+
// Cancel a pending auto-advance on unmount, and whenever the active question
70+
// changes or a response goes in flight — otherwise a manual Next/Submit landing
71+
// inside the 200ms window leaves a stale timer that advances or submits again.
6672
useEffect(() => {
6773
return () => {
6874
if (autoAdvanceTimerRef.current !== null) {
6975
window.clearTimeout(autoAdvanceTimerRef.current);
76+
autoAdvanceTimerRef.current = null;
7077
}
7178
};
72-
}, []);
79+
}, [activeQuestion?.id, isResponding]);
7380

74-
const handleOptionSelection = useCallback(
75-
(questionId: string, optionLabel: string) => {
76-
onToggleOption(questionId, optionLabel);
77-
if (activeQuestion?.multiSelect) {
78-
return;
79-
}
80-
if (autoAdvanceTimerRef.current !== null) {
81-
window.clearTimeout(autoAdvanceTimerRef.current);
82-
}
83-
autoAdvanceTimerRef.current = window.setTimeout(() => {
84-
autoAdvanceTimerRef.current = null;
85-
onAdvance();
86-
}, 200);
87-
},
88-
[activeQuestion?.multiSelect, onAdvance, onToggleOption],
89-
);
81+
const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
82+
onToggleOption(questionId, optionLabel);
83+
if (activeQuestion?.multiSelect) {
84+
return;
85+
}
86+
if (autoAdvanceTimerRef.current !== null) {
87+
window.clearTimeout(autoAdvanceTimerRef.current);
88+
}
89+
autoAdvanceTimerRef.current = window.setTimeout(() => {
90+
autoAdvanceTimerRef.current = null;
91+
onAdvanceRef.current();
92+
}, 200);
93+
});
9094

9195
// Keyboard shortcut: digits toggle options for multi-select prompts and preserve
9296
// the current auto-advance behavior for single-select questions.
@@ -117,7 +121,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
117121
};
118122
document.addEventListener("keydown", handler);
119123
return () => document.removeEventListener("keydown", handler);
120-
}, [activeQuestion, handleOptionSelection, isResponding]);
124+
}, [activeQuestion, isResponding]);
121125

122126
if (!activeQuestion) {
123127
return null;

0 commit comments

Comments
 (0)