Skip to content
Merged
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
97 changes: 97 additions & 0 deletions crates/agenthub-core/src/utils/stream_parse/claude/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,100 @@ fn bash_tool_use_still_emits_process_step() {
}))
.is_empty());
}

#[test]
fn hyphenated_plan_tool_names_and_nested_event_unwrap() {
assert!(super::is_claude_plan_tool_name("Todo-Write"));
assert!(super::is_claude_plan_tool_name("Task Create"));
assert!(super::is_claude_plan_tool_name("todo_read"));
assert!(!super::is_claude_plan_tool_name("Bash"));
let payload = serde_json::json!({
"event": {
"type": "assistant",
"message": {
"content": [{
"type": "tool_use",
"id": "toolu_todo",
"name": "Todo-Write",
"input": {
"todos": [
{ "title": "read docs", "status": "pending" },
{ "content": " " }
]
}
}]
}
}
});
match &super::extract_todo_plan(&payload)[0] {
super::ClaudePlanOp::Replace(entries) => {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].content, "read docs");
assert_eq!(entries[0].status.as_deref(), Some("pending"));
}
other => panic!("expected replace, got {other:?}"),
}
assert_eq!(
super::plan_tool_use_ids(payload.get("event").expect("event")),
vec!["toolu_todo".to_string()]
);
}

#[test]
fn content_block_task_create_and_skipped_read_tools() {
let create = serde_json::json!({
"type": "content_block_start",
"content_block": {
"type": "tool_use",
"id": "toolu_create",
"name": "TaskCreate",
"input": { "title": "build auth", "priority": "high" }
}
});
assert_eq!(
super::extract_todo_plan(&create),
vec![super::ClaudePlanOp::Create {
tool_use_id: Some("toolu_create".into()),
content: "build auth".into(),
status: Some("pending".into()),
id: None,
priority: Some("high".into()),
}]
);
assert!(super::extract_todo_plan(&serde_json::json!({
"type": "tool_use",
"name": "TodoRead",
"input": { "todos": [{ "content": "ignore" }] }
}))
.is_empty());
assert!(super::extract_todo_plan(&serde_json::json!({
"type": "tool_use",
"name": "TaskUpdate",
"input": { "status": "in_progress" }
}))
.is_empty());
assert!(super::extract_todo_plan(&serde_json::json!({
"type": "tool_use",
"name": "TaskCreate",
"input": { "status": "pending" }
}))
.is_empty());
}

#[test]
fn task_update_accepts_task_id_alias() {
let update = serde_json::json!({
"type": "tool_use",
"name": "TaskUpdate",
"input": { "task_id": "task-3", "subject": "rewrite", "status": "completed" }
});
assert_eq!(
super::extract_todo_plan(&update),
vec![super::ClaudePlanOp::Update {
id: "task-3".into(),
status: Some("completed".into()),
content: Some("rewrite".into()),
priority: None,
}]
);
}
3 changes: 2 additions & 1 deletion e2e/browser/chat-overflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ test('selected Agent has no duplicate overflow menu; other entries stay', async
await expect(composer.getByRole('button', { name: '更多操作' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '更多操作' })).toHaveCount(0);

await expect(page.getByRole('button', { name: '新建对话' })).toBeVisible();
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible();
await expect(page.locator('[data-help="chat-new"]')).toHaveCount(1);
await expect(page.getByLabel('搜索标题或工作目录')).toBeVisible();
await expect(page.getByRole('button', { name: '会话设置' })).toBeVisible();

Expand Down
61 changes: 61 additions & 0 deletions src/lib/chat-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,3 +1083,64 @@ describe('chat-process human tool labels', () => {
expect(isProtocolProcessStep({ type: 'tool', name: 'Read', status: 'start' })).toBe(false);
});
});

describe('thinking / tools pane helpers', () => {
it('keeps thinking-only timelines off the tool chip', () => {
const thinking = [{ type: 'thinking' as const, text: 'plan', done: false }];
expect(latestThinkingStep(thinking)?.text).toBe('plan');
expect(latestThinkingStep([])).toBeUndefined();
expect(latestThinkingStep(undefined)).toBeUndefined();
expect(timelineHasToolRow(thinking)).toBe(false);
expect(showBubbleThinkingBar(thinking, false)).toBe(true);
expect(formatProcessHeadline(thinking, 'running', t)).toBe('思考中');
expect(thinkingElapsedMs(undefined, 1000)).toBe(0);
expect(thinkingElapsedMs({
turn: 1,
agent: 'codex',
phase: 'running',
stdout: '',
stderr: '',
steps: thinking,
updatedAt: 1,
}, 1000)).toBe(0);
expect(thinkingElapsedMs({
turn: 1,
agent: 'codex',
phase: 'ok',
stdout: '',
stderr: '',
steps: thinking,
updatedAt: 1,
thinkingStartedAt: 500,
thinkingDurationMs: -12,
}, 900)).toBe(0);
});

it('folds same-id execute updates and skips blank command output', () => {
expect(timelineProcessSteps([
{ type: 'raw', text: ' ', note: 'command output' },
{ type: 'tool', id: 'run-1', name: 'Bash', status: 'start', input: { command: 'ls' } },
{ type: 'tool', id: 'run-1', name: 'Bash', status: 'end', result: 'docs' },
{ type: 'status', phase: 'starting', detail: 'thread.started' },
{ type: 'usage', scope: 'turn', input: 1, output: 1 },
])).toEqual([
{
type: 'tool',
id: 'run-1',
name: 'Bash',
status: 'end',
input: { command: 'ls' },
result: 'docs',
},
]);
expect(timelineHasToolRow([
{ type: 'tool', name: 'Bash', status: 'end' },
{ type: 'error', message: 'boom' },
])).toBe(true);
expect(formatProcessHeadline(
[{ type: 'tool', name: 'Bash', status: 'error', input: { command: 'ls' } }],
'failed',
t,
)).toBe('没法执行 ls');
});
});
63 changes: 63 additions & 0 deletions src/pages/chat/ChatMessageBubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,69 @@ describe('ChatMessageBubble streaming feel', () => {
expect(html).not.toContain('done thinking body');
});

it('hides the thinking bar once the reply body arrives', () => {
const process: AgentProcessView = {
turn: 1,
agent: 'codex',
phase: 'running',
stdout: '',
stderr: '',
steps: [{ type: 'thinking', text: 'secret plan', done: true }],
updatedAt: 1,
thinkingStartedAt: 1,
thinkingDurationMs: 1200,
};
const html = renderToStaticMarkup(
createElement(TooltipProvider, null, createElement(ChatMessageBubble, {
message: agentMessage('第一段正文'),
process,
isLastTurn: true,
multiAgent: false,
retryDisabled: false,
onRetry: () => undefined,
onOpenProcess: () => undefined,
})),
);
expect(html).not.toContain('data-help="chat-thinking-bar"');
expect(html).not.toContain('secret plan');
expect(html).toContain('第一段正文');
});

it('shows the thinking bar and a tool chip together before any body', () => {
const process: AgentProcessView = {
turn: 1,
agent: 'codex',
phase: 'running',
stdout: '',
stderr: '',
steps: [
{ type: 'thinking', text: 'secret plan', done: false },
{ type: 'tool', name: 'Read', status: 'start', input: { path: 'README.md' } },
],
updatedAt: 1,
thinkingStartedAt: Date.now() - 1500,
};
const html = renderToStaticMarkup(
createElement(TooltipProvider, null, createElement(ChatMessageBubble, {
message: agentMessage(''),
process,
isLastTurn: true,
multiAgent: false,
retryDisabled: false,
onRetry: () => undefined,
onOpenProcess: () => undefined,
})),
);
expect(html).toContain('data-help="chat-thinking-bar"');
expect(html).toContain('data-help="chat-process-chip"');
expect(html).toContain('正在读取 README.md');
expect(html).not.toContain('secret plan');
const barAt = html.indexOf('data-help="chat-thinking-bar"');
const chipAt = html.indexOf('data-help="chat-process-chip"');
expect(barAt).toBeGreaterThan(-1);
expect(chipAt).toBeGreaterThan(barAt);
});

it('opens process details from a one-line chip', () => {
const process: AgentProcessView = {
turn: 1,
Expand Down
125 changes: 123 additions & 2 deletions src/pages/chat/ChatOutlineRail.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StorageKey } from '@/lib/storage-key';
import type { ChatMessage } from '@/lib/types';
import type { TurnGroup } from './chat-format';
import { ChatOutlineRail } from './ChatOutlineRail';
import { saveChatOutlineEnabled } from './chat-outline-pref';

function user(id: string, content: string): ChatMessage {
return {
Expand Down Expand Up @@ -35,11 +37,107 @@ function renderRail(props: {
createElement(ChatOutlineRail, {
turns: props.turns,
measuredWidth: props.measuredWidth,
enabled: props.enabled ?? true,
...(props.enabled === undefined ? {} : { enabled: props.enabled }),
}) as ReactElement,
);
}

function hasOutline(html: string): boolean {
return html.includes('data-testid="chat-outline-rail"');
}

describe('ChatOutlineRail visibility gates', () => {
it('shows the rail only when the setting, two user messages, and 720px all hold', () => {
expect(hasOutline(renderRail({
turns: turns('first', 'second'),
measuredWidth: 720,
enabled: true,
}))).toBe(true);
expect(hasOutline(renderRail({
turns: turns('first', 'second', 'third'),
measuredWidth: 800,
enabled: true,
}))).toBe(true);
});

it('hides when any one gate fails', () => {
expect(hasOutline(renderRail({
turns: turns('first', 'second'),
measuredWidth: 720,
enabled: false,
}))).toBe(false);
expect(hasOutline(renderRail({
turns: turns('only one'),
measuredWidth: 800,
enabled: true,
}))).toBe(false);
expect(hasOutline(renderRail({
turns: [],
measuredWidth: 800,
enabled: true,
}))).toBe(false);
expect(hasOutline(renderRail({
turns: turns('first', 'second'),
measuredWidth: 719,
enabled: true,
}))).toBe(false);
expect(hasOutline(renderRail({
turns: turns('first', 'second'),
measuredWidth: 0,
enabled: true,
}))).toBe(false);
});

it('does not count agent-only turns toward the two-prompt gate', () => {
const oneUser = [
{ turn: 1, user: user('u1', 'only user'), agents: [] },
{ turn: 2, agents: [] },
{ turn: 3, agents: [] },
];
expect(hasOutline(renderRail({ turns: oneUser, measuredWidth: 800, enabled: true }))).toBe(false);
expect(renderRail({ turns: oneUser, measuredWidth: 800, enabled: true })).toBe('');
});

it('returns nothing when the setting is off or there are fewer than two user messages', () => {
expect(renderRail({
turns: turns('first', 'second'),
measuredWidth: 800,
enabled: false,
})).toBe('');
expect(renderRail({ turns: turns('only one'), measuredWidth: 800 })).toBe('');
});

describe('stored preference when enabled is omitted', () => {
const store = new Map<string, string>();

beforeEach(() => {
store.clear();
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('defaults on, and hides after the preference is saved off', () => {
const wideTwo = { turns: turns('first', 'second'), measuredWidth: 720 };
expect(hasOutline(renderRail(wideTwo))).toBe(true);
saveChatOutlineEnabled(false);
expect(store.get(StorageKey.chatOutlineEnabled)).toBe('0');
expect(hasOutline(renderRail(wideTwo))).toBe(false);
expect(renderRail(wideTwo)).toBe('');
});
});
});

describe('ChatOutlineRail markup', () => {
it('does not draw a rail for one user message', () => {
const html = renderRail({ turns: turns('only one'), measuredWidth: 800 });
Expand Down Expand Up @@ -71,4 +169,27 @@ describe('ChatOutlineRail markup', () => {
});
expect(html).not.toContain('chat-outline-rail');
});

it('keeps a measure wrapper when two prompts are too narrow to draw ticks', () => {
const html = renderRail({ turns: turns('first', 'second'), measuredWidth: 719 });
expect(html).toContain('pointer-events-none');
expect(html).not.toContain('data-testid="chat-outline-rail"');
});

it('skips agent-only turns and still jumps by the user message id', () => {
const html = renderRail({
turns: [
{ turn: 1, user: user('u1', 'first'), agents: [] },
{ turn: 2, agents: [] },
{ turn: 3, user: user('u3', 'third'), agents: [] },
],
measuredWidth: 800,
});
expect(html).toContain('data-testid="chat-outline-tick-u1"');
expect(html).toContain('data-testid="chat-outline-tick-u3"');
expect(html).not.toContain('chat-outline-tick-u2');
expect(html).toContain('1 / 2:first');
expect(html).toContain('2 / 2:third');
expect(html).not.toContain('data-testid="chat-outline-preview"');
});
});
Loading
Loading