Files
hikari-desktop/src/lib/utils/stateMapper.test.ts
T
hikari bf411adeb7
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
fix: critical permission modal and config issues (#127)
## Summary

This PR resolves several critical bugs that were blocking the permission modal and causing config loss:

- **Permission modal not appearing** - Fixed z-index issues and runtime errors
- **Config store race condition** - Resolved critical race condition causing settings to be lost
- **Excessive logging** - Removed redundant fmt layer that was writing to hidden stdout
- **System tool prompts** - Prevented unnecessary permission prompts for built-in tools
- **Permission batching** - Added support for parallel permission requests
- **ExitPlanMode tool** - Fixed ExitPlanMode tool not functioning correctly

## Changes Made

### Permission Modal Fixes
- Updated z-index to proper value (9999) to ensure modal appears above all other UI elements
- Fixed runtime errors that were preventing modal from rendering
- Resolved issues with permission grants not being properly applied

### Config Store Race Condition
- Fixed critical race condition where multiple rapid config updates would result in lost settings
- Ensured config writes are properly sequenced to prevent data loss
- Added proper synchronisation for config store operations

### Logging Cleanup
- Removed redundant fmt formatting layer that was outputting to hidden stdout
- Cleaned up excessive debug logging added during troubleshooting
- Removed temporary debugging documentation files

### UX Improvements
- Added close confirmation modal with minimise to tray option
- Implemented batching for parallel permission requests
- Added debug console for viewing frontend and backend logs

### ExitPlanMode Fix
- Fixed ExitPlanMode tool not functioning correctly, ensuring proper transitions out of plan mode

## Issues Resolved

Closes #112 - Permission flow now properly handles multiple tool requests
Closes #113 - ExitPlanMode tool now functions correctly
Closes #126 - Debug console feature added (partial - basic implementation complete)

## Test Plan

- [x] Permission modal appears and functions correctly
- [x] Config settings persist across app restarts
- [x] No excessive logging in production builds
- [x] System tools don't trigger permission prompts
- [x] Parallel permission requests are properly batched
- [x] Debug console displays frontend and backend logs
- [x] ExitPlanMode properly exits plan mode

---
 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #127
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 01:55:49 -08:00

416 lines
12 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { mapMessageToState, extractTextFromMessage, extractToolInfo } from "./stateMapper";
import type { ClaudeStreamMessage } from "$lib/types/messages";
describe("stateMapper", () => {
describe("mapMessageToState", () => {
it("returns idle for system init message", () => {
const message: ClaudeStreamMessage = {
type: "system",
subtype: "init",
session_id: "test-session",
cwd: "/home/test",
tools: ["Read", "Write", "Edit"],
};
expect(mapMessageToState(message)).toBe("idle");
});
it("returns null for non-init system messages", () => {
const message: ClaudeStreamMessage = {
type: "system",
subtype: "compact_boundary",
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns searching for Read tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Read",
input: { file_path: "/test/file.txt" },
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("searching");
});
it("returns coding for Edit tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Edit",
input: { file_path: "/test/file.txt", old_string: "foo", new_string: "bar" },
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("coding");
});
it("returns mcp for mcp__ prefixed tools", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "mcp__github__list_repos",
input: {},
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("mcp");
});
it("returns thinking for Task tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Task",
input: { prompt: "test task" },
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text content", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [{ type: "text", text: "Hello, Naomi!" }],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns success for result success message", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "success",
result: "Task completed",
};
expect(mapMessageToState(message)).toBe("success");
});
it("returns error for result error message", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "error_max_turns",
};
expect(mapMessageToState(message)).toBe("error");
});
it("returns null for user messages", () => {
const message: ClaudeStreamMessage = {
type: "user",
message: { content: [{ type: "text", text: "Hello" }] },
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns typing for unknown tool", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "SomeUnknownTool",
input: {},
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content block", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [{ type: "thinking", thinking: "Analyzing the problem..." }],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns null for assistant message with no recognizable content", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns thinking for thinking_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "thinking_delta",
thinking: "Thinking...",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text_delta stream event", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: "Hello",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns thinking for thinking content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "thinking",
thinking: "",
},
},
};
expect(mapMessageToState(message)).toBe("thinking");
});
it("returns typing for text content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(mapMessageToState(message)).toBe("typing");
});
it("returns correct state for tool_use content_block_start", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tool-1",
name: "Read",
input: {},
},
},
};
expect(mapMessageToState(message)).toBe("searching");
});
it("returns null for stream_event with unrecognized type", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "message_start",
},
};
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for result with unknown subtype", () => {
const message = {
type: "result",
subtype: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown_type",
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
});
describe("extractTextFromMessage", () => {
it("extracts text from assistant message", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{ type: "text", text: "Hello!" },
{ type: "text", text: "How are you?" },
],
model: "claude-3",
stop_reason: "end_turn",
},
};
expect(extractTextFromMessage(message)).toBe("Hello!\nHow are you?");
});
it("returns null for assistant message without text", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Read",
input: { file_path: "/test/file.txt" },
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("extracts text from stream_event delta", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "streaming text" },
},
};
expect(extractTextFromMessage(message)).toBe("streaming text");
});
it("extracts result from result message", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "success",
result: "Completed successfully",
};
expect(extractTextFromMessage(message)).toBe("Completed successfully");
});
it("returns null for result without result field", () => {
const message: ClaudeStreamMessage = {
type: "result",
subtype: "success",
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for stream_event without delta text", () => {
const message: ClaudeStreamMessage = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "",
},
},
};
expect(extractTextFromMessage(message)).toBeNull();
});
it("returns null for unknown message type", () => {
const message = {
type: "unknown",
} as unknown as ClaudeStreamMessage;
expect(extractTextFromMessage(message)).toBeNull();
});
});
describe("extractToolInfo", () => {
it("extracts tool info from assistant message", () => {
const message: ClaudeStreamMessage = {
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Read",
input: { file_path: "/test/file.txt" },
},
{
type: "tool_use",
id: "tool-2",
name: "Edit",
input: { file_path: "/test/file.txt", old_string: "a", new_string: "b" },
},
],
model: "claude-3",
stop_reason: "tool_use",
},
};
const tools = extractToolInfo(message);
expect(tools).toHaveLength(2);
expect(tools[0]).toEqual({
name: "Read",
input: { file_path: "/test/file.txt" },
});
expect(tools[1]).toEqual({
name: "Edit",
input: { file_path: "/test/file.txt", old_string: "a", new_string: "b" },
});
});
it("returns empty array for non-assistant messages", () => {
const message: ClaudeStreamMessage = {
type: "user",
message: { content: [{ type: "text", text: "Hello" }] },
};
expect(extractToolInfo(message)).toEqual([]);
});
});
});