Skip to content

Commit 2c67630

Browse files
committed
fix(web): failed-turn Continue submits a fixed prompt, not the last user message
1 parent 0048151 commit 2c67630

4 files changed

Lines changed: 138 additions & 20 deletions

File tree

apps/pythinker-web/src/components/chat/ChatPane.vue

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -268,15 +268,14 @@ const emit = defineEmits<{
268268
editMessage: [payload: { text: string; attachments?: TurnAttachment[] }];
269269
/** Fetch the next older page of messages (triggered by top sentinel visibility or click). */
270270
loadOlderMessages: [];
271-
/** Remove a queued message by index. */
272271
unqueue: [index: number];
273272
/** Load a queued message back into the composer for editing (and dequeue it). */
274273
editQueued: [index: number];
275274
/** Drag-to-reorder a queued message within the active session's queue. */
276275
reorderQueue: [payload: { from: number; to: number }];
277276
/**
278-
* Failed-turn recovery: re-send the last user prompt (approximation of the
279-
* reference's daemon-side resumeTurn — the wire has no resume endpoint).
277+
* Failed-turn recovery: submit a fixed "Continue" prompt (no attachments),
278+
* mirroring the reference client's resume path.
280279
*/
281280
continueTurn: [text: string];
282281
}>();
@@ -663,21 +662,13 @@ function runIsStreaming(
663662
return last !== undefined && last.sourceIndex === turnBlocks(turn).length - 1;
664663
}
665664
666-
// Failed-turn recovery: re-send the LAST USER prompt through the ordinary send
667-
// path. The reference's daemon-side resumeTurn does not exist on this wire, so
668-
// the closest approximation is resubmitting the user's own text.
669-
function lastUserPrompt(): string {
670-
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
671-
const turn = props.turns[index];
672-
if (turn && turn.role === 'user' && turn.text.trim().length > 0) return turn.text;
673-
}
674-
return '';
675-
}
676-
665+
// Failed-turn recovery: submit a fixed "Continue" prompt with no attachments,
666+
// matching the reference client (its ConversationPane submits
667+
// `conversation.turnFailedResumeText` through the ordinary send path). The
668+
// user's own last message is deliberately NOT re-sent: that would repeat its
669+
// instructions and any side effects.
677670
function continueFailedTurn(): void {
678-
const text = lastUserPrompt();
679-
if (text.length === 0) return;
680-
emit('continueTurn', text);
671+
emit('continueTurn', t('conversation.turnFailedResumeText'));
681672
}
682673
683674
// NOTE: the turn-summary line ("Called N tools...") was removed in f9417af. If it

apps/pythinker-web/src/components/chat/ConversationPane.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,6 @@ const emit = defineEmits<{
148148
refreshGitStatus: [];
149149
/** Edit + resend the last user message (App undoes, then refills composer). */
150150
editMessage: [payload: { text: string; attachments?: TurnAttachment[] }];
151-
/** Failed-turn recovery: re-send the last user prompt (see ChatPane). */
152-
continueTurn: [text: string];
153151
/** Empty-composer workspace picker: start a new conversation elsewhere. */
154152
selectWorkspace: [workspaceId: string];
155153
/** Empty-composer workspace picker: create a new workspace. */
@@ -1519,7 +1517,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
15191517
@unqueue="emit('unqueue', $event)"
15201518
@edit-queued="handleEditQueued"
15211519
@reorder-queue="handleReorderQueue"
1522-
@continue-turn="emit('continueTurn', $event)"
1520+
@continue-turn="(text) => handleComposerSubmit({ text, attachments: [] })"
15231521
/>
15241522
</template>
15251523
</div>

apps/pythinker-web/src/i18n/locales/en/conversation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export default {
8181
restoreTableWidth: 'Restore default width',
8282
// S3 parity — failed-turn recovery banner (ChatPane.vue)
8383
turnFailedResume: 'Continue',
84+
/** Fixed prompt text submitted by the failed-turn "Continue" button. */
85+
turnFailedResumeText: 'Continue',
8486
// S3 parity — ActivityRun aggregate run blocks (ActivityRun.vue). Clause
8587
// summaries mirror the reference `tools.activity` / `tools.group.typed`.
8688
activityRun: {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// apps/pythinker-web/test/continue-turn.test.ts
2+
//
3+
// Failed-turn recovery parity with the reference client: the banner's
4+
// "Continue" button must submit a FIXED "Continue" prompt (i18n key
5+
// conversation.turnFailedResumeText) with no attachments — never a re-send of
6+
// the user's own last message, which would repeat its instructions and side
7+
// effects.
8+
import { mount } from '@vue/test-utils';
9+
import { createI18n, type I18n } from 'vue-i18n';
10+
import { defineComponent } from 'vue';
11+
import { describe, expect, it, vi } from 'vitest';
12+
import ChatPane from '../src/components/chat/ChatPane.vue';
13+
import type { ChatTurn } from '../src/types';
14+
15+
vi.mock('markstream-vue', () => {
16+
const noop = (): void => undefined;
17+
return {
18+
MarkdownRender: defineComponent({
19+
name: 'MarkdownRenderStub',
20+
props: ['content'],
21+
setup(props) {
22+
return () => String(props.content ?? '');
23+
},
24+
}),
25+
enableKatex: noop,
26+
enableMermaid: noop,
27+
setKaTeXWorker: noop,
28+
clearKaTeXWorker: noop,
29+
setMermaidWorker: noop,
30+
clearMermaidWorker: noop,
31+
};
32+
});
33+
vi.mock('markstream-vue/workers/katexRenderer.worker?worker&type=module', () => ({
34+
default: class {
35+
terminate(): void {}
36+
},
37+
}));
38+
vi.mock('markstream-vue/workers/mermaidParser.worker?worker&type=module', () => ({
39+
default: class {
40+
terminate(): void {}
41+
},
42+
}));
43+
44+
const i18n = createI18n({
45+
legacy: false,
46+
locale: 'en',
47+
messages: {
48+
en: {
49+
conversation: {
50+
turnFailed: 'Model request failed — this turn was interrupted',
51+
turnFailedMaxSteps: 'Step limit reached — this turn was interrupted',
52+
turnFailedResume: 'Continue',
53+
turnFailedResumeText: 'Continue',
54+
},
55+
},
56+
},
57+
});
58+
59+
const turns: ChatTurn[] = [
60+
{ id: 'u1', role: 'user', no: 1, text: 'delete everything in /tmp' },
61+
{
62+
id: 'a1',
63+
role: 'assistant',
64+
no: 2,
65+
text: '',
66+
tools: [{ id: 'tool_1', name: 'Bash', arg: '{}', status: 'error', output: ['boom'] }],
67+
},
68+
];
69+
70+
function mountPane() {
71+
return mount(ChatPane, {
72+
props: {
73+
turns,
74+
turnActive: false,
75+
working: false,
76+
lastTurnReason: 'failed',
77+
turnErrorKind: 'error',
78+
},
79+
global: { plugins: [i18n as I18n] },
80+
});
81+
}
82+
83+
describe('failed-turn recovery (ChatPane)', () => {
84+
it('submits the fixed Continue prompt, not the last user message', async () => {
85+
const wrapper = mountPane();
86+
vi.stubGlobal(
87+
'ResizeObserver',
88+
class {
89+
observe(): void {}
90+
unobserve(): void {}
91+
disconnect(): void {}
92+
},
93+
);
94+
await wrapper.find('.turn-failed button').trigger('click');
95+
const emitted = wrapper.emitted('continueTurn');
96+
expect(emitted).toEqual([['Continue']]);
97+
// Guard against regression to the old resubmission behavior: the emitted
98+
// text must not be the user's prior prompt.
99+
expect(emitted![0]![0]).not.toBe('delete everything in /tmp');
100+
vi.unstubAllGlobals();
101+
});
102+
103+
it('renders the max-steps banner variant and still submits the fixed prompt', async () => {
104+
const wrapper = mount(ChatPane, {
105+
props: {
106+
turns,
107+
turnActive: false,
108+
working: false,
109+
lastTurnReason: 'failed',
110+
turnErrorKind: 'max_steps',
111+
},
112+
global: { plugins: [i18n as I18n] },
113+
});
114+
vi.stubGlobal(
115+
'ResizeObserver',
116+
class {
117+
observe(): void {}
118+
unobserve(): void {}
119+
disconnect(): void {}
120+
},
121+
);
122+
expect(wrapper.find('.tf-title').text()).toContain('Step limit reached');
123+
await wrapper.find('.turn-failed button').trigger('click');
124+
expect(wrapper.emitted('continueTurn')).toEqual([['Continue']]);
125+
vi.unstubAllGlobals();
126+
});
127+
});

0 commit comments

Comments
 (0)