Update files

This commit is contained in:
Mohamed Boudra
2026-02-08 21:12:40 +07:00
parent 925752c8a3
commit 8317b0d89b
11 changed files with 1391 additions and 407 deletions

View File

@@ -363,6 +363,7 @@ export function AgentStreamView({
return (
<ToolCall
toolName={data.name}
provider={data.provider}
args={data.input}
result={data.result}
error={data.error}
@@ -907,8 +908,12 @@ function PermissionRequestCard({
if (isPlanRequest) {
return null;
}
return parseToolCallDisplay({ name: request.name ?? "unknown", input: request.input });
}, [isPlanRequest, request.name, request.input]);
return parseToolCallDisplay({
name: request.name ?? "unknown",
provider: request.provider,
input: request.input,
});
}, [isPlanRequest, request.name, request.provider, request.input]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);

View File

@@ -420,7 +420,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
position: "absolute",
top: 0,
bottom: 0,
width: 100,
},
}));
@@ -1017,7 +1016,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
if (isLoading) {
shimmer.value = -1;
shimmer.value = withRepeat(
withTiming(1, { duration: 2400, easing: Easing.bezier(0.4, 0, 0.6, 1) }),
withTiming(1, { duration: 3200, easing: Easing.linear }),
-1
);
} else {
@@ -1026,7 +1025,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
}, [isLoading]);
const shimmerBandWidth = 100;
const shimmerBandWidth = 14;
const shimmerStyle = useAnimatedStyle(() => {
const travel = badgeWidth + shimmerBandWidth;
return {
@@ -1098,7 +1097,11 @@ const ExpandableBadge = memo(function ExpandableBadge({
{isLoading && badgeWidth > 0 ? (
<Animated.View
pointerEvents="none"
style={[expandableBadgeStylesheet.shimmerOverlay, shimmerStyle]}
style={[
expandableBadgeStylesheet.shimmerOverlay,
{ width: shimmerBandWidth },
shimmerStyle,
]}
>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -1109,10 +1112,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
x2="1"
y2="0"
>
<Stop offset="0" stopColor={theme.colors.surface1} stopOpacity="0" />
<Stop offset="0.35" stopColor={theme.colors.surface1} stopOpacity="1" />
<Stop offset="0.65" stopColor={theme.colors.surface1} stopOpacity="1" />
<Stop offset="1" stopColor={theme.colors.surface1} stopOpacity="0" />
<Stop offset="0" stopColor={baseColors.white} stopOpacity="0" />
<Stop offset="0.34" stopColor={baseColors.white} stopOpacity="0" />
<Stop offset="0.46" stopColor={baseColors.white} stopOpacity="0.12" />
<Stop offset="0.5" stopColor={baseColors.white} stopOpacity="0.34" />
<Stop offset="0.54" stopColor={baseColors.white} stopOpacity="0.12" />
<Stop offset="0.66" stopColor={baseColors.white} stopOpacity="0" />
<Stop offset="1" stopColor={baseColors.white} stopOpacity="0" />
</LinearGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#shimmerGrad)" />
@@ -1149,6 +1155,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
interface ToolCallProps {
toolName: string;
provider?: string;
args: any;
result?: any;
error?: any;
@@ -1176,6 +1183,7 @@ const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
export const ToolCall = memo(function ToolCall({
toolName,
provider,
args,
result,
error,
@@ -1197,8 +1205,17 @@ export const ToolCall = memo(function ToolCall({
UnistylesRuntime.breakpoint === "sm";
const displayInfo = useMemo(
() => parseToolCallDisplay({ name: toolName, input: args, output: result, error, metadata, cwd }),
[toolName, args, result, error, metadata, cwd]
() =>
parseToolCallDisplay({
name: toolName,
provider,
input: args,
output: result,
error,
metadata,
cwd,
}),
[toolName, provider, args, result, error, metadata, cwd]
);
const { kind, displayName, summary, detail, errorText } = displayInfo;
const IconComponent = toolKindIcons[kind] || Wrench;

View File

@@ -384,6 +384,54 @@ function testToolCallParsedPayloadHydration() {
assert.ok(commandPass, 'Command payload should persist across hydration');
}
function testNullToolPayloadDoesNotEraseKnownInput() {
const timestampStart = new Date("2025-01-01T10:36:00Z");
const timestampFinish = new Date("2025-01-01T10:36:05Z");
const callId = "null-input-preserve";
const updates: Array<{ event: AgentStreamEventPayload; timestamp: Date }> = [
{
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
name: "shell",
status: "pending",
callId,
input: { command: "pwd" },
},
},
timestamp: timestampStart,
},
{
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
name: "shell",
status: "completed",
callId,
input: null,
output: { type: "command", output: "/tmp" },
},
},
timestamp: timestampFinish,
},
];
const state = hydrateStreamState(updates);
const commandEntry = state.find(
(item): item is AgentToolCallItem =>
isAgentToolCallItem(item) && item.payload.data.callId === callId
);
assert.ok(commandEntry, "Tool call should exist");
assert.strictEqual(commandEntry.payload.data.status, "completed");
assert.deepStrictEqual(commandEntry.payload.data.input, { command: "pwd" });
}
function buildClaudeToolUseBlock({
id,
name,
@@ -1116,6 +1164,7 @@ describe('stream timeline reducers', () => {
it('infers failure from error payloads', testToolCallFailureInferenceFromError);
it('reconciles late call IDs against pending entries', testToolCallLateCallIdReconciliation);
it('persists parsed read/edit/command payloads after hydration', testToolCallParsedPayloadHydration);
it('preserves known input when later tool updates send null input', testNullToolPayloadDoesNotEraseKnownInput);
it('hydrates Claude tool bodies with parsed content', testClaudeHydratedToolBodies);
it('preserves whitespace in assistant chunk concatenation', testAssistantWhitespacePreservation);
it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);

View File

@@ -431,15 +431,15 @@ function appendAgentToolCall(
const next = [...state];
const existing = next[existingIndex] as AgentToolCallItem;
const mergedInput =
payloadData.input !== undefined
hasValue(payloadData.input)
? payloadData.input
: existing.payload.data.input;
const mergedResult =
payloadData.result !== undefined
hasValue(payloadData.result)
? payloadData.result
: existing.payload.data.result;
const mergedError =
payloadData.error !== undefined
hasValue(payloadData.error)
? payloadData.error
: existing.payload.data.error;
const mergedStatus = mergeToolCallStatus(

View File

@@ -64,6 +64,35 @@ describe("parseToolCallDisplay", () => {
}
});
test("falls back to output command when shell input is missing", () => {
const output = { type: "command", command: "pwd", output: "/some/path" };
const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Bash", output });
expect(info.summary).toBe("pwd");
expect(info.detail.type).toBe("shell");
if (info.detail.type === "shell") {
expect(info.detail.command).toBe("pwd");
expect(info.detail.output).toBe("/some/path");
}
});
test("falls back to read output path when input is missing", () => {
const info: ToolCallDisplayInfo = parseToolCallDisplay({
name: "Read",
output: {
type: "file_read",
filePath: "/some/file.txt",
content: "hello",
},
});
expect(info.summary).toBe("/some/file.txt");
expect(info.detail.type).toBe("read");
if (info.detail.type === "read") {
expect(info.detail.filePath).toBe("/some/file.txt");
expect(info.detail.content).toBe("hello");
}
});
test("strips shell + cd wrapper from command (Codex exec_command style)", () => {
const input = {
command:
@@ -110,6 +139,14 @@ describe("parseToolCallDisplay", () => {
expect(info.displayName).toBe("Read");
});
test("uses stable label for Edit in frontend", () => {
const input = { file_path: "/some/file.txt", old_string: "a", new_string: "b" };
const first = parseToolCallDisplay({ name: "Edit", input });
const second = parseToolCallDisplay({ name: "Edit", input });
expect(first.displayName).toBe("Edit");
expect(second.displayName).toBe("Edit");
});
test("normalizes tool names - paseo_voice.speak to Speak", () => {
const input = { text: "hello from namespaced speak" };
const info = parseToolCallDisplay({ name: "paseo_voice.speak", input });
@@ -128,6 +165,15 @@ describe("parseToolCallDisplay", () => {
expect(info.displayName).toBe("MyCustomTool");
});
test("does not let Task metadata override non-Task summary", () => {
const info = parseToolCallDisplay({
name: "shell",
input: { command: "pwd" },
metadata: { subAgentActivity: "Read" },
});
expect(info.summary).toBe("pwd");
});
test("parses non-command tool call into generic detail", () => {
const input = { file_path: "/some/file.txt" };
const output = { content: "file contents here", lineCount: 42 };
@@ -141,12 +187,16 @@ describe("parseToolCallDisplay", () => {
}
});
test("handles file_write output as generic", () => {
test("handles file_write output as edit", () => {
const input = { file_path: "/some/file.txt", content: "new content" };
const output = { type: "file_write", filePath: "/some/file.txt" };
const info: ToolCallDisplayInfo = parseToolCallDisplay({ name: "Write", input, output });
expect(info.detail.type).toBe("generic");
expect(info.detail.type).toBe("edit");
expect(info.summary).toBe("/some/file.txt");
if (info.detail.type === "edit") {
expect(info.detail.filePath).toBe("/some/file.txt");
}
});
test("handles undefined input and output gracefully", () => {
@@ -366,14 +416,13 @@ describe("parseToolCallDisplay - read_file (Codex)", () => {
}
});
test("Codex read_file falls through to generic when result is missing", () => {
test("Codex read_file stays read when result is missing", () => {
const input = {
path: "/some/file.txt",
};
const info = parseToolCallDisplay({ name: "read_file", input });
// Without result, it can't match the schema so falls through to generic
expect(info.detail.type).toBe("generic");
expect(info.detail.type).toBe("read");
expect(info.displayName).toBe("Read");
});
});

View File

@@ -16,6 +16,7 @@
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",

View File

@@ -0,0 +1,180 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { STTManager } from "../src/server/agent/stt-manager.js";
import { createRootLogger } from "../src/server/logger.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import {
DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL,
LocalSttModelIdSchema,
type LocalSttModelId,
} from "../src/server/speech/providers/local/models.js";
import { initializeLocalSpeechServices } from "../src/server/speech/providers/local/runtime.js";
import type { RequestedSpeechProviders } from "../src/server/speech/speech-types.js";
type CliOptions = {
wavPath: string;
outPath?: string;
model: LocalSttModelId;
modelsDir: string;
autoDownload: boolean;
};
function usage(): string {
return [
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>] [--no-auto-download]",
"",
"Examples:",
" npm run speech:transcribe:local -- ./sample.wav",
" npm run speech:transcribe:local -- ./sample.wav --out ./tmp/sample.transcript.txt",
"",
"Env fallbacks:",
" PASEO_LOCAL_MODELS_DIR, PASEO_LOCAL_STT_MODEL",
].join("\n");
}
function parseArgs(argv: string[]): CliOptions {
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write(`${usage()}\n`);
process.exit(0);
}
if (argv.length === 0) {
throw new Error(`Missing <wavPath>\n\n${usage()}`);
}
const paseoHome = resolvePaseoHome();
const defaultModelsDir =
process.env.PASEO_LOCAL_MODELS_DIR ?? path.join(paseoHome, "models", "local-speech");
const positional: string[] = [];
let outPath: string | undefined;
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
let modelsDir = defaultModelsDir;
let autoDownload = true;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--out") {
const next = argv[i + 1];
if (!next) {
throw new Error("--out requires a value");
}
outPath = path.resolve(next);
i += 1;
continue;
}
if (arg === "--model") {
const next = argv[i + 1];
if (!next) {
throw new Error("--model requires a value");
}
model = LocalSttModelIdSchema.parse(next);
i += 1;
continue;
}
if (arg === "--models-dir") {
const next = argv[i + 1];
if (!next) {
throw new Error("--models-dir requires a value");
}
modelsDir = path.resolve(next);
i += 1;
continue;
}
if (arg === "--no-auto-download") {
autoDownload = false;
continue;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
positional.push(arg);
}
if (positional.length === 0) {
throw new Error(`Missing <wavPath>\n\n${usage()}`);
}
return {
wavPath: path.resolve(positional[0]),
...(outPath ? { outPath } : {}),
model,
modelsDir,
autoDownload,
};
}
async function main(): Promise<void> {
let options: CliOptions;
try {
options = parseArgs(process.argv.slice(2));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exit(2);
return;
}
const logger = createRootLogger({ level: "info", format: "pretty" });
const providers: RequestedSpeechProviders = {
dictationStt: { provider: "local", explicit: true },
voiceStt: { provider: "local", explicit: true },
// Not used here, but required by the shared runtime config shape.
voiceTts: { provider: "openai", explicit: false },
};
const runtime = await initializeLocalSpeechServices({
providers,
speechConfig: {
providers,
local: {
modelsDir: options.modelsDir,
autoDownload: options.autoDownload,
models: {
dictationStt: options.model,
voiceStt: options.model,
voiceTts: DEFAULT_LOCAL_TTS_MODEL,
},
},
},
logger,
});
try {
if (!runtime.sttService) {
throw new Error(
"Local STT service is unavailable. Check model files or run `npm run speech:download -- --model " +
options.model +
"`."
);
}
const audio = await readFile(options.wavPath);
const manager = new STTManager("dev-local-wav-transcribe", logger, runtime.sttService);
const result = await manager.transcribe(audio, "audio/wav", { label: "dev-local-wav-transcribe" });
const transcript = result.text.trim();
if (options.outPath) {
await mkdir(path.dirname(options.outPath), { recursive: true });
await writeFile(options.outPath, `${transcript}\n`, "utf8");
logger.info({ outPath: options.outPath }, "Wrote transcript");
}
process.stdout.write(`${transcript}\n`);
} finally {
runtime.cleanup();
}
}
await main();

View File

@@ -159,7 +159,11 @@ export function curateAgentActivity(
case "tool_call": {
flushBuffers(lines, buffers);
const inputJson = formatToolInputJson(item.input);
const { displayName, summary } = parseToolCallDisplay({ name: item.name, input: item.input, metadata: item.metadata });
const { displayName, summary } = parseToolCallDisplay({
name: item.name,
input: item.input,
metadata: item.metadata,
});
if (isLikelyExternalToolName(item.name) && inputJson) {
lines.push(`[${displayName}] ${inputJson}`);
break;

View File

@@ -98,6 +98,7 @@ describe("parseToolCallDisplay", () => {
describe("summary (was extractPrincipalParam)", () => {
test("extracts and strips shell wrapper from command string", () => {
const result = parseToolCallDisplay({
provider: "claude",
name: "Bash",
input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" },
});
@@ -120,6 +121,14 @@ describe("parseToolCallDisplay", () => {
expect(result.summary).toBe("npm run build");
});
test("falls back to output command when shell input is missing", () => {
const result = parseToolCallDisplay({
name: "Bash",
output: { type: "command", command: "pwd", output: "/tmp" },
});
expect(result.summary).toBe("pwd");
});
test("extracts file_path and strips cwd", () => {
const result = parseToolCallDisplay({
name: "Read",
@@ -129,6 +138,34 @@ describe("parseToolCallDisplay", () => {
expect(result.summary).toBe("src/file.ts");
});
test("falls back to read output path when input is missing", () => {
const result = parseToolCallDisplay({
provider: "codex",
name: "Read",
output: {
type: "file_read",
filePath: "/Users/dev/project/src/file.ts",
content: "hello",
},
cwd: "/Users/dev/project",
});
expect(result.summary).toBe("src/file.ts");
});
test("falls back to edit output path when input is missing", () => {
const result = parseToolCallDisplay({
name: "Edit",
output: {
type: "file_edit",
filePath: "/Users/dev/project/src/file.ts",
oldContent: "a",
newContent: "b",
},
cwd: "/Users/dev/project",
});
expect(result.summary).toBe("src/file.ts");
});
test("extracts pattern without modification", () => {
const result = parseToolCallDisplay({
name: "Grep",
@@ -168,7 +205,7 @@ describe("parseToolCallDisplay", () => {
});
describe("summary from metadata", () => {
test("subAgentActivity in metadata takes priority over input", () => {
test("subAgentActivity in metadata takes priority for Task", () => {
const result = parseToolCallDisplay({
name: "Task",
input: { description: "Explore codebase" },
@@ -185,6 +222,15 @@ describe("parseToolCallDisplay", () => {
});
expect(result.summary).toBe("Bash");
});
test("non-Task tools keep their parsed summary even when metadata is present", () => {
const result = parseToolCallDisplay({
name: "Bash",
input: { command: "pwd" },
metadata: { subAgentActivity: "Read" },
});
expect(result.summary).toBe("pwd");
});
});
describe("kind", () => {

File diff suppressed because it is too large Load Diff

4
paseo@0.1.0 Normal file
View File

@@ -0,0 +1,4 @@
> paseo@0.1.0 cli
> npx tsx packages/cli/src/index.js logs cli --filter tools