Skip to content

Commit e8fa4d0

Browse files
committed
fix: clear live tool intent when a step retries
A stale intent label survived into the retried step. Also guard the deliberately duplicated ANSI sanitizer with mirrored test fixtures on both sides so future drift breaks a test.
1 parent af5f1bc commit e8fa4d0

6 files changed

Lines changed: 55 additions & 20 deletions

File tree

.changeset/tool-intent-indicator.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@pythoughts/pythinker-code": minor
33
---
44

5-
Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a random verb; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`.
5+
Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a rotating placeholder; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`.

apps/pythinker-code/src/tui/constant/rendering.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export const THINKING_SPINNER_LABELS = [
133133
export const THINKING_SPINNER_LABEL_INTERVAL_MS = 12_000;
134134

135135
const LIVE_INTENT_MAX_LENGTH = 120;
136+
// Keep in sync with packages/agent-core/src/loop/tool-intent.ts.
136137
// oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences.
137138
const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu;
138139
const CONTROL_CHARACTER = /\p{Cc}/gu;

apps/pythinker-code/src/tui/controllers/session-event-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ export class SessionEventHandler {
287287
case 'turn.step.started': this.handleStepBegin(event); break;
288288
case 'turn.step.interrupted': this.handleStepInterrupted(event); break;
289289
case 'turn.step.completed': this.handleStepCompleted(event); break;
290-
case 'turn.step.retrying': break;
290+
case 'turn.step.retrying': setLiveIntent(undefined); break;
291291
case 'tool.progress': this.handleToolProgress(event); break;
292292
case 'assistant.delta': this.handleAssistantDelta(event); break;
293293
case 'hook.result': this.handleHookResult(event); break;

apps/pythinker-code/test/tui/tool-intent-label.test.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import {
88
} from '#/tui/constant/rendering';
99
import { PythinkerTUI, type PythinkerTUIStartupInput } from '#/tui/pythinker-tui';
1010

11+
const SANITIZER_FIXTURES = [
12+
['\u001B[31mred\u001B[0m', 'red'],
13+
['\u001B]0;title\u0007visible', 'visible'],
14+
['\u001B]0;title\u001B\\visible', 'visible'],
15+
['check\n\u0007test', 'check test'],
16+
] as const;
17+
1118
function makeStartupInput(): PythinkerTUIStartupInput {
1219
return {
1320
cliOptions: {
@@ -49,16 +56,9 @@ describe('tool intent thinking label', () => {
4956
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
5057
});
5158

52-
it('removes control characters before display', () => {
53-
setLiveIntent('\u001B[31mcheck\n\u0007 failing test\u001B[0m');
54-
expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…');
55-
});
56-
57-
it('removes ST-terminated OSC hyperlinks before display', () => {
58-
setLiveIntent(
59-
'\u001B]8;;https://example.com\u001B\\click\u001B]8;;\u001B\\ done',
60-
);
61-
expect(formatThinkingSpinnerLabel(0)).toBe('click done…');
59+
it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => {
60+
setLiveIntent(raw);
61+
expect(formatThinkingSpinnerLabel(0)).toBe(`${expected}…`);
6262
});
6363

6464
it('sets intent from a tool delta and clears it on the result', () => {
@@ -115,4 +115,36 @@ describe('tool intent thinking label', () => {
115115

116116
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
117117
});
118+
119+
it('clears the live intent when a step retries', () => {
120+
const driver = new PythinkerTUI({} as never, makeStartupInput());
121+
const dispatch = (event: Event): void =>
122+
driver.sessionEventHandler.handleEvent(event, vi.fn());
123+
124+
dispatch({
125+
type: 'tool.call.started',
126+
agentId: 'main',
127+
sessionId: 'session-1',
128+
turnId: 1,
129+
toolCallId: 'call-1',
130+
name: 'echo',
131+
args: {},
132+
intent: 'check failing test',
133+
});
134+
dispatch({
135+
type: 'turn.step.retrying',
136+
agentId: 'main',
137+
sessionId: 'session-1',
138+
turnId: 1,
139+
step: 1,
140+
failedAttempt: 1,
141+
nextAttempt: 2,
142+
maxAttempts: 3,
143+
delayMs: 100,
144+
errorName: 'Error',
145+
errorMessage: 'retry',
146+
});
147+
148+
expect(formatThinkingSpinnerLabel(0)).toBe('thinking…');
149+
});
118150
});

packages/agent-core/src/loop/tool-intent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const INTENT_MAX_LENGTH = 120;
55
/** Tool names excluded from intent injection. StructuredOutput is mechanical and has an exact schema contract. */
66
export const INTENT_OMIT_TOOLS: ReadonlySet<string> = new Set(['StructuredOutput']);
77

8+
// Keep in sync with apps/pythinker-code/src/tui/constant/rendering.ts.
89
// oxlint-disable-next-line no-control-regex -- model-authored terminal text must not retain escape sequences.
910
const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu;
1011
const CONTROL_CHARACTER = /\p{Cc}/gu;

packages/agent-core/test/loop/tool-intent.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import {
1010
sanitizeIntent,
1111
} from '../../src/loop/tool-intent';
1212

13+
const SANITIZER_FIXTURES = [
14+
['\u001B[31mred\u001B[0m', 'red'],
15+
['\u001B]0;title\u0007visible', 'visible'],
16+
['\u001B]0;title\u001B\\visible', 'visible'],
17+
['check\n\u0007test', 'check test'],
18+
] as const;
19+
1320
function makeTool(
1421
name = 'test',
1522
parameters: Record<string, unknown> = {
@@ -112,14 +119,8 @@ describe('tool intent extraction', () => {
112119
});
113120

114121
describe('tool intent sanitization', () => {
115-
it('strips terminal escapes and controls and collapses whitespace', () => {
116-
expect(sanitizeIntent('\u001B[31mcheck\n\u0007 failing\u001B[0m')).toBe('check failing');
117-
});
118-
119-
it('preserves visible text after an ST-terminated OSC hyperlink', () => {
120-
expect(sanitizeIntent('\u001B]8;;https://example.com\u001B\\click\u001B]8;;\u001B\\ done')).toBe(
121-
'click done',
122-
);
122+
it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => {
123+
expect(sanitizeIntent(raw)).toBe(expected);
123124
});
124125

125126
it('caps the result by code points', () => {

0 commit comments

Comments
 (0)