feat: stream persisted history and show workspace kind indicators
This commit is contained in:
@@ -25,7 +25,7 @@ import { navigateToWorkspace } from '@/hooks/use-workspace-navigation'
|
|||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import { type GestureType } from 'react-native-gesture-handler'
|
import { type GestureType } from 'react-native-gesture-handler'
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import { Archive, ChevronDown, ChevronRight, Copy, MoreVertical, Plus } from 'lucide-react-native'
|
import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, Monitor, MoreVertical, Plus } from 'lucide-react-native'
|
||||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||||
@@ -160,18 +160,26 @@ function resolveStatusDotColor(input: {
|
|||||||
|
|
||||||
function WorkspaceStatusIndicator({
|
function WorkspaceStatusIndicator({
|
||||||
bucket,
|
bucket,
|
||||||
|
workspaceKind,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
bucket: SidebarWorkspaceEntry['statusBucket']
|
bucket: SidebarWorkspaceEntry['statusBucket']
|
||||||
|
workspaceKind: SidebarWorkspaceEntry['workspaceKind']
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const color = resolveStatusDotColor({ theme, bucket })
|
const color = resolveStatusDotColor({ theme, bucket })
|
||||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
|
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
|
||||||
const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done'
|
const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done'
|
||||||
|
|
||||||
if (shouldRenderIdlePlaceholder) {
|
if (isIdle) {
|
||||||
return null
|
const KindIcon = workspaceKind === 'local_checkout' ? Monitor : workspaceKind === 'worktree' ? FolderGit2 : null
|
||||||
|
if (!KindIcon) return null
|
||||||
|
return (
|
||||||
|
<View style={styles.workspaceStatusDot}>
|
||||||
|
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({
|
|||||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||||
) : shouldShowSyncedLoader ? (
|
) : shouldShowSyncedLoader ? (
|
||||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||||
|
) : bucket === 'needs_input' ? (
|
||||||
|
<CircleHelp size={14} color={theme.colors.palette.amber[500]} />
|
||||||
|
) : bucket === 'attention' ? (
|
||||||
|
<Check size={14} color={theme.colors.palette.green[500]} />
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +225,7 @@ function ProjectLeadingVisual({
|
|||||||
{showChevron && chevron !== null ? (
|
{showChevron && chevron !== null ? (
|
||||||
<ProjectInlineChevron chevron={chevron} />
|
<ProjectInlineChevron chevron={chevron} />
|
||||||
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||||
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
|
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} workspaceKind={activeWorkspace.workspaceKind} loading={isArchiving} />
|
||||||
) : iconDataUri ? (
|
) : iconDataUri ? (
|
||||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||||
) : (
|
) : (
|
||||||
@@ -686,6 +698,7 @@ function WorkspaceRowInner({
|
|||||||
>
|
>
|
||||||
<WorkspaceStatusIndicator
|
<WorkspaceStatusIndicator
|
||||||
bucket={workspace.statusBucket}
|
bucket={workspace.statusBucket}
|
||||||
|
workspaceKind={workspace.workspaceKind}
|
||||||
loading={isArchiving || isCreating}
|
loading={isArchiving || isCreating}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -1903,7 +1916,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceRowLeft: {
|
workspaceRowLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[2],
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
@@ -1938,7 +1951,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
workspaceStatusDot: {
|
workspaceStatusDot: {
|
||||||
width: 11,
|
width: 14,
|
||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|||||||
@@ -147,6 +147,45 @@ describe("convertClaudeHistoryEntry", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps queue-operation task notifications to synthetic tool calls", () => {
|
||||||
|
const entry = {
|
||||||
|
type: "queue-operation",
|
||||||
|
operation: "enqueue",
|
||||||
|
uuid: "task-note-queue-1",
|
||||||
|
content: [
|
||||||
|
"<task-notification>",
|
||||||
|
"<task-id>bg-queue-1</task-id>",
|
||||||
|
"<status>completed</status>",
|
||||||
|
"<summary>Background task completed</summary>",
|
||||||
|
"<output-file>/tmp/bg-queue-1.txt</output-file>",
|
||||||
|
"</task-notification>",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "task_notification_task-note-queue-1",
|
||||||
|
name: "task_notification",
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "plain_text",
|
||||||
|
label: "Background task completed",
|
||||||
|
icon: "wrench",
|
||||||
|
text: entry.content,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
synthetic: true,
|
||||||
|
source: "claude_task_notification",
|
||||||
|
taskId: "bg-queue-1",
|
||||||
|
status: "completed",
|
||||||
|
outputFile: "/tmp/bg-queue-1.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("passes assistant content blocks through to the mapper", () => {
|
test("passes assistant content blocks through to the mapper", () => {
|
||||||
const entry = {
|
const entry = {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
mapClaudeRunningToolCall,
|
mapClaudeRunningToolCall,
|
||||||
} from "./claude/tool-call-mapper.js";
|
} from "./claude/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
|
coerceTaskNotificationHistoryRecordToSystemMessage,
|
||||||
isTaskNotificationUserContent,
|
isTaskNotificationUserContent,
|
||||||
mapTaskNotificationSystemRecordToToolCall,
|
mapTaskNotificationSystemRecordToToolCall,
|
||||||
mapTaskNotificationUserContentToToolCall,
|
mapTaskNotificationUserContentToToolCall,
|
||||||
@@ -1495,6 +1496,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private readonly timelineAssembler = new TimelineAssembler();
|
private readonly timelineAssembler = new TimelineAssembler();
|
||||||
private persistedHistory: AgentTimelineItem[] = [];
|
private persistedHistory: AgentTimelineItem[] = [];
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
|
private historyOffsetSessionId: string | null = null;
|
||||||
|
private historyReadOffsetBytes = 0;
|
||||||
|
private historyLineFragment = "";
|
||||||
private turnState: TurnState = "idle";
|
private turnState: TurnState = "idle";
|
||||||
private preReplayMetadataSeen = false;
|
private preReplayMetadataSeen = false;
|
||||||
private pendingAutonomousWakeReservations = 0;
|
private pendingAutonomousWakeReservations = 0;
|
||||||
@@ -3074,6 +3078,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
|
this.historyOffsetSessionId = null;
|
||||||
|
this.historyReadOffsetBytes = 0;
|
||||||
|
this.historyLineFragment = "";
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
this.queryRestartNeeded = false;
|
this.queryRestartNeeded = false;
|
||||||
return true;
|
return true;
|
||||||
@@ -3998,46 +4005,163 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private waitForLiveHistoryPoll(): Promise<void> {
|
private async waitForLiveHistoryPoll(): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
if (this.claudeSessionId) {
|
||||||
|
this.loadPersistedHistory(this.claudeSessionId, { dispatchLive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadPersistedHistory(sessionId: string) {
|
private loadPersistedHistory(
|
||||||
|
sessionId: string,
|
||||||
|
options?: { dispatchLive?: boolean }
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const historyPath = this.resolveHistoryPath(sessionId);
|
const historyPath = this.resolveHistoryPath(sessionId);
|
||||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = fs.readFileSync(historyPath, "utf8");
|
if (this.historyOffsetSessionId !== sessionId) {
|
||||||
const timeline: AgentTimelineItem[] = [];
|
this.historyOffsetSessionId = sessionId;
|
||||||
for (const line of content.split(/\n+/)) {
|
this.historyReadOffsetBytes = 0;
|
||||||
const trimmed = line.trim();
|
this.historyLineFragment = "";
|
||||||
if (!trimmed) continue;
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(trimmed);
|
|
||||||
if (entry.isSidechain) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.type === "user" && typeof entry.uuid === "string") {
|
|
||||||
this.rememberUserMessageId(entry.uuid);
|
|
||||||
}
|
|
||||||
const items = this.convertHistoryEntry(entry);
|
|
||||||
if (items.length > 0) {
|
|
||||||
timeline.push(...items);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// ignore malformed history line
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (timeline.length > 0) {
|
const content = fs.readFileSync(historyPath);
|
||||||
this.persistedHistory = timeline;
|
if (content.byteLength < this.historyReadOffsetBytes) {
|
||||||
this.historyPending = true;
|
this.historyReadOffsetBytes = 0;
|
||||||
|
this.historyLineFragment = "";
|
||||||
}
|
}
|
||||||
|
if (content.byteLength === this.historyReadOffsetBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadChunk = content
|
||||||
|
.subarray(this.historyReadOffsetBytes)
|
||||||
|
.toString("utf8");
|
||||||
|
this.historyReadOffsetBytes = content.byteLength;
|
||||||
|
this.ingestPersistedHistoryChunk(unreadChunk, {
|
||||||
|
dispatchLive: options?.dispatchLive ?? false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore history load failures
|
// ignore history load failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ingestPersistedHistoryChunk(
|
||||||
|
chunk: string,
|
||||||
|
options: { dispatchLive: boolean }
|
||||||
|
): void {
|
||||||
|
if (!chunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${this.historyLineFragment}${chunk}`;
|
||||||
|
this.historyLineFragment = "";
|
||||||
|
const lines = combined.split(/\r?\n/);
|
||||||
|
const trailing = lines.pop() ?? "";
|
||||||
|
const timeline: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
this.ingestPersistedHistoryLine(line, {
|
||||||
|
dispatchLive: options.dispatchLive,
|
||||||
|
timeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trailing.trim().length > 0) {
|
||||||
|
const handled = this.ingestPersistedHistoryLine(trailing, {
|
||||||
|
dispatchLive: options.dispatchLive,
|
||||||
|
timeline,
|
||||||
|
});
|
||||||
|
if (!handled) {
|
||||||
|
this.historyLineFragment = trailing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.dispatchLive && timeline.length > 0) {
|
||||||
|
this.persistedHistory = [...this.persistedHistory, ...timeline];
|
||||||
|
this.historyPending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ingestPersistedHistoryLine(
|
||||||
|
line: string,
|
||||||
|
options: {
|
||||||
|
dispatchLive: boolean;
|
||||||
|
timeline: AgentTimelineItem[];
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isSidechain) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.type === "user" && typeof entry.uuid === "string") {
|
||||||
|
this.rememberUserMessageId(entry.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dispatchLive) {
|
||||||
|
this.dispatchPersistedHistoryEntry(entry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this.convertHistoryEntry(entry);
|
||||||
|
if (items.length > 0) {
|
||||||
|
options.timeline.push(...items);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchPersistedHistoryEntry(entry: Record<string, unknown>): void {
|
||||||
|
const liveMessage = this.normalizePersistedHistoryEntryToLiveMessage(entry);
|
||||||
|
if (liveMessage) {
|
||||||
|
this.routeSdkMessageFromPump(liveMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this.convertHistoryEntry(entry);
|
||||||
|
for (const item of items) {
|
||||||
|
this.pushEvent({
|
||||||
|
type: "timeline",
|
||||||
|
item,
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePersistedHistoryEntryToLiveMessage(
|
||||||
|
entry: Record<string, unknown>
|
||||||
|
): SDKMessage | null {
|
||||||
|
const taskNotificationMessage =
|
||||||
|
coerceTaskNotificationHistoryRecordToSystemMessage(entry);
|
||||||
|
if (taskNotificationMessage) {
|
||||||
|
return taskNotificationMessage as unknown as SDKMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = readTrimmedString(entry.type);
|
||||||
|
switch (type) {
|
||||||
|
case "assistant":
|
||||||
|
case "result":
|
||||||
|
case "stream_event":
|
||||||
|
case "system":
|
||||||
|
case "tool_progress":
|
||||||
|
case "user":
|
||||||
|
return entry as unknown as SDKMessage;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resolveHistoryPath(sessionId: string): string | null {
|
private resolveHistoryPath(sessionId: string): string | null {
|
||||||
const cwd = this.config.cwd;
|
const cwd = this.config.cwd;
|
||||||
if (!cwd) return null;
|
if (!cwd) return null;
|
||||||
@@ -4624,13 +4748,9 @@ export function convertClaudeHistoryEntry(
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === "system") {
|
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry);
|
||||||
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(
|
if (taskNotificationItem) {
|
||||||
entry
|
return [taskNotificationItem];
|
||||||
);
|
|
||||||
if (taskNotificationItem) {
|
|
||||||
return [taskNotificationItem];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isCompactSummary) {
|
if (entry.isCompactSummary) {
|
||||||
|
|||||||
+149
-1
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
@@ -22,6 +22,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|||||||
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||||
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
||||||
|
const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed";
|
||||||
|
const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER";
|
||||||
|
|
||||||
function buildSdkQueryMock() {
|
function buildSdkQueryMock() {
|
||||||
const events = [
|
const events = [
|
||||||
@@ -70,6 +72,19 @@ function buildSdkQueryMock() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildIdleSdkQueryMock() {
|
||||||
|
return {
|
||||||
|
next: vi.fn(async () => ({ done: true, value: undefined })),
|
||||||
|
interrupt: vi.fn(async () => undefined),
|
||||||
|
return: vi.fn(async () => undefined),
|
||||||
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
|
setModel: vi.fn(async () => undefined),
|
||||||
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
|
supportedCommands: vi.fn(async () => []),
|
||||||
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function collectTimelineText(events: AgentStreamEvent[]): string {
|
function collectTimelineText(events: AgentStreamEvent[]): string {
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -86,6 +101,28 @@ function collectTimelineText(events: AgentStreamEvent[]): string {
|
|||||||
return chunks.join("\n");
|
return chunks.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readNextEvent(
|
||||||
|
iterator: AsyncIterator<AgentStreamEvent>,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<AgentStreamEvent> {
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
iterator.next().then((result) => ({ kind: "result" as const, result })),
|
||||||
|
new Promise<{ kind: "timeout" }>((resolve) => {
|
||||||
|
setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (outcome.kind === "timeout") {
|
||||||
|
throw new Error("Timed out waiting for live event");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.result.done) {
|
||||||
|
throw new Error("Live event stream ended before appended transcript arrived");
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome.result.value;
|
||||||
|
}
|
||||||
|
|
||||||
describe("ClaudeAgentSession history replay regression", () => {
|
describe("ClaudeAgentSession history replay regression", () => {
|
||||||
let tempRoot: string;
|
let tempRoot: string;
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
@@ -215,6 +252,117 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
|
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("emits appended transcript lines through streamLiveEvents after history was primed", async () => {
|
||||||
|
sdkMocks.query.mockImplementation(() => {
|
||||||
|
const mock = buildIdleSdkQueryMock();
|
||||||
|
sdkMocks.lastQuery = mock;
|
||||||
|
return mock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const logger = createTestLogger();
|
||||||
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
const handle: AgentPersistenceHandle = {
|
||||||
|
provider: "claude",
|
||||||
|
sessionId: "history-session",
|
||||||
|
nativeHandle: "history-session",
|
||||||
|
metadata: {
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
|
||||||
|
const historyPath = path.join(
|
||||||
|
configDir,
|
||||||
|
"projects",
|
||||||
|
sanitized,
|
||||||
|
"history-session.jsonl"
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = await client.resumeSession(handle, { cwd });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _event of session.streamHistory()) {
|
||||||
|
// Prime existing persisted history the same way agent-manager does.
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveEvents = session.streamLiveEvents();
|
||||||
|
const iterator = liveEvents[Symbol.asyncIterator]();
|
||||||
|
|
||||||
|
appendFileSync(
|
||||||
|
historyPath,
|
||||||
|
`\n${JSON.stringify({
|
||||||
|
type: "queue-operation",
|
||||||
|
operation: "enqueue",
|
||||||
|
uuid: "appended-task-note-1",
|
||||||
|
content: [
|
||||||
|
"<task-notification>",
|
||||||
|
"<task-id>appended-bg-1</task-id>",
|
||||||
|
"<status>completed</status>",
|
||||||
|
`<summary>${APPENDED_TASK_NOTIFICATION_MARKER}</summary>`,
|
||||||
|
"<output-file>/tmp/appended-bg-1.txt</output-file>",
|
||||||
|
"</task-notification>",
|
||||||
|
].join("\n"),
|
||||||
|
})}\n${JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
sessionId: "history-session",
|
||||||
|
cwd,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: APPENDED_ASSISTANT_MARKER,
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const appendedEvents: AgentStreamEvent[] = [];
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
appendedEvents.push(await readNextEvent(iterator, 1_500));
|
||||||
|
const sawTaskNotification = appendedEvents.some(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "tool_call" &&
|
||||||
|
event.item.name === "task_notification"
|
||||||
|
);
|
||||||
|
const sawAssistant = appendedEvents.some(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "assistant_message" &&
|
||||||
|
event.item.text.includes(APPENDED_ASSISTANT_MARKER)
|
||||||
|
);
|
||||||
|
if (sawTaskNotification && sawAssistant) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timelineText = collectTimelineText(appendedEvents);
|
||||||
|
const taskNotificationEvent = appendedEvents.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "tool_call" &&
|
||||||
|
event.item.name === "task_notification"
|
||||||
|
);
|
||||||
|
const turnStartedEvent = appendedEvents.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "turn_started" }> =>
|
||||||
|
event.type === "turn_started"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskNotificationEvent).toBeTruthy();
|
||||||
|
expect(turnStartedEvent).toBeTruthy();
|
||||||
|
expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER);
|
||||||
|
expect(taskNotificationEvent?.item.metadata).toMatchObject({
|
||||||
|
taskId: "appended-bg-1",
|
||||||
|
status: "completed",
|
||||||
|
outputFile: "/tmp/appended-bg-1.txt",
|
||||||
|
});
|
||||||
|
expect(taskNotificationEvent?.item.detail).toMatchObject({
|
||||||
|
type: "plain_text",
|
||||||
|
label: APPENDED_TASK_NOTIFICATION_MARKER,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("listCommands includes rewind command", async () => {
|
test("listCommands includes rewind command", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ const TaskNotificationUserContentSchema = z.union([
|
|||||||
z.array(TaskNotificationUserContentBlockSchema),
|
z.array(TaskNotificationUserContentBlockSchema),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TaskNotificationSystemRecordSchema = z
|
const TaskNotificationHistoryRecordSchema = z
|
||||||
.object({
|
.object({
|
||||||
subtype: z.literal("task_notification"),
|
type: z.string().optional(),
|
||||||
|
subtype: z.string().optional(),
|
||||||
uuid: z.string().optional(),
|
uuid: z.string().optional(),
|
||||||
message_id: z.string().optional(),
|
message_id: z.string().optional(),
|
||||||
task_id: z.string().optional(),
|
task_id: z.string().optional(),
|
||||||
@@ -57,13 +58,24 @@ export type MapTaskNotificationUserContentToToolCallInput = z.infer<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
type TaskNotificationUserContent = z.infer<typeof TaskNotificationUserContentSchema>;
|
type TaskNotificationUserContent = z.infer<typeof TaskNotificationUserContentSchema>;
|
||||||
type TaskNotificationSystemRecord = z.infer<typeof TaskNotificationSystemRecordSchema>;
|
type TaskNotificationHistoryRecord = z.infer<typeof TaskNotificationHistoryRecordSchema>;
|
||||||
|
|
||||||
type TaskNotificationLifecycle =
|
type TaskNotificationLifecycle =
|
||||||
| { status: "completed"; error: null }
|
| { status: "completed"; error: null }
|
||||||
| { status: "failed"; error: unknown }
|
| { status: "failed"; error: unknown }
|
||||||
| { status: "canceled"; error: null };
|
| { status: "canceled"; error: null };
|
||||||
|
|
||||||
|
export type TaskNotificationSystemMessageLike = {
|
||||||
|
type: "system";
|
||||||
|
subtype: "task_notification";
|
||||||
|
uuid?: string;
|
||||||
|
task_id?: string;
|
||||||
|
status?: "completed" | "failed" | "stopped";
|
||||||
|
summary?: string;
|
||||||
|
output_file?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ReadTaskNotificationTagInput = {
|
type ReadTaskNotificationTagInput = {
|
||||||
text: string;
|
text: string;
|
||||||
tagName: string;
|
tagName: string;
|
||||||
@@ -157,12 +169,18 @@ function parseTaskNotificationFromUserContent(
|
|||||||
function parseTaskNotificationFromSystemRecord(
|
function parseTaskNotificationFromSystemRecord(
|
||||||
record: unknown
|
record: unknown
|
||||||
): TaskNotificationEnvelope | null {
|
): TaskNotificationEnvelope | null {
|
||||||
const parsedRecord = TaskNotificationSystemRecordSchema.safeParse(record);
|
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
|
||||||
if (!parsedRecord.success) {
|
if (!parsedRecord.success) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemRecord: TaskNotificationSystemRecord = parsedRecord.data;
|
const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data;
|
||||||
|
const isSystemTaskNotification =
|
||||||
|
systemRecord.type === "system" && systemRecord.subtype === "task_notification";
|
||||||
|
const isQueueOperation = systemRecord.type === "queue-operation";
|
||||||
|
if (!isSystemTaskNotification && !isQueueOperation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const rawText = toNonEmptyString(systemRecord.content);
|
const rawText = toNonEmptyString(systemRecord.content);
|
||||||
|
|
||||||
return TaskNotificationEnvelopeSchema.parse({
|
return TaskNotificationEnvelopeSchema.parse({
|
||||||
@@ -330,3 +348,31 @@ export function mapTaskNotificationSystemRecordToToolCall(
|
|||||||
}
|
}
|
||||||
return toTaskNotificationToolCall(parsed);
|
return toTaskNotificationToolCall(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function coerceTaskNotificationHistoryRecordToSystemMessage(
|
||||||
|
record: unknown
|
||||||
|
): TaskNotificationSystemMessageLike | null {
|
||||||
|
const parsed = parseTaskNotificationFromSystemRecord(record);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedStatus = parsed.status?.toLowerCase() ?? null;
|
||||||
|
const status =
|
||||||
|
normalizedStatus === "failed" || normalizedStatus === "error"
|
||||||
|
? "failed"
|
||||||
|
: normalizedStatus === "canceled" || normalizedStatus === "cancelled"
|
||||||
|
? "stopped"
|
||||||
|
: "completed";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_notification",
|
||||||
|
...(parsed.messageId ? { uuid: parsed.messageId } : {}),
|
||||||
|
...(parsed.taskId ? { task_id: parsed.taskId } : {}),
|
||||||
|
status,
|
||||||
|
...(parsed.summary ? { summary: parsed.summary } : {}),
|
||||||
|
...(parsed.outputFile ? { output_file: parsed.outputFile } : {}),
|
||||||
|
...(parsed.rawText ? { content: parsed.rawText } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1109,36 +1109,44 @@ export async function getCheckoutShortstat(
|
|||||||
|
|
||||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||||
if (!localBaseRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
if (currentBranch === localBaseRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(
|
let diffTarget: string;
|
||||||
cwd,
|
|
||||||
normalizeLocalBranchRefName(localBaseRef)
|
|
||||||
);
|
|
||||||
|
|
||||||
let mergeBase: string;
|
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
||||||
try {
|
// Feature branch: diff against the merge-base with the base branch
|
||||||
const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, {
|
const comparisonBaseRef = await resolveBestComparisonBaseRef(
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
normalizeLocalBranchRefName(localBaseRef)
|
||||||
});
|
);
|
||||||
mergeBase = stdout.trim();
|
|
||||||
if (!mergeBase) {
|
try {
|
||||||
|
const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
const mergeBase = stdout.trim();
|
||||||
|
if (!mergeBase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
diffTarget = mergeBase;
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch {
|
} else if (currentBranch) {
|
||||||
|
// On the base branch (or no base ref configured): diff against remote tracking branch
|
||||||
|
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
||||||
|
if (!hasOrigin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
diffTarget = `origin/${currentBranch}`;
|
||||||
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git diff --shortstat ${mergeBase} HEAD`, {
|
// Omit HEAD so the diff includes uncommitted (staged + unstaged) changes
|
||||||
|
const { stdout } = await execAsync(`git diff --shortstat ${diffTarget}`, {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user