refactor: standardize pending permission keying and user message hydration
This commit is contained in:
@@ -342,19 +342,13 @@ export function AgentStreamView({
|
||||
ListHeaderComponent={
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission, index) => {
|
||||
const permissionKey = permission.request.id
|
||||
? `${permission.agentId}:${permission.request.id}`
|
||||
: `${permission.agentId}:permission-${permission.request.name ?? permission.request.title ?? index}`;
|
||||
|
||||
return (
|
||||
<PermissionRequestCard
|
||||
key={permissionKey}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ import type {
|
||||
import { ScrollView } from "react-native";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
|
||||
const fallbackId =
|
||||
request.id ||
|
||||
(typeof request.metadata?.id === "string" ? request.metadata.id : undefined) ||
|
||||
request.name ||
|
||||
request.title ||
|
||||
`${request.kind}:${JSON.stringify(request.input ?? request.metadata ?? request.raw ?? {})}`;
|
||||
|
||||
return `${agentId}:${fallbackId}`;
|
||||
};
|
||||
|
||||
export type MessageEntry =
|
||||
| {
|
||||
type: "user";
|
||||
@@ -286,7 +297,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
const agent = normalizeAgentSnapshot(snapshot);
|
||||
nextAgents.set(agent.id, agent);
|
||||
for (const request of agent.pendingPermissions) {
|
||||
nextPermissions.set(request.id, { agentId: agent.id, request });
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
nextPermissions.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +336,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}
|
||||
}
|
||||
for (const request of agent.pendingPermissions) {
|
||||
next.set(request.id, { agentId: agent.id, request });
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
next.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -413,7 +426,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
|
||||
setPendingPermissions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(request.id, { agentId, request });
|
||||
const key = derivePendingPermissionKey(agentId, request);
|
||||
next.set(key, { key, agentId, request });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
@@ -427,7 +441,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
|
||||
setPendingPermissions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(requestId);
|
||||
const derivedKey = `${agentId}:${requestId}`;
|
||||
if (!next.delete(derivedKey)) {
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === agentId && pending.request.id === requestId) {
|
||||
next.delete(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SelectedToolCall {
|
||||
* Uses actual ACP types instead of any
|
||||
*/
|
||||
export interface PendingPermission {
|
||||
key: string;
|
||||
agentId: string;
|
||||
request: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hydrateStreamState, type StreamItem, type ToolCallItem } from "./stream";
|
||||
import type { AgentStreamEventPayload } from "@server/server/messages";
|
||||
|
||||
type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date };
|
||||
|
||||
const HARNESS_CALL_IDS = {
|
||||
command: "harness-command",
|
||||
edit: "harness-edit",
|
||||
read: "harness-read",
|
||||
};
|
||||
|
||||
const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Create a README snippet, show me the diff, and run ls.",
|
||||
messageId: "msg-live-user",
|
||||
},
|
||||
},
|
||||
timestamp: new Date("2025-02-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
callId: HARNESS_CALL_IDS.edit,
|
||||
server: "editor",
|
||||
tool: "apply_patch",
|
||||
input: {
|
||||
file_path: "README.md",
|
||||
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch",
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:01Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolResultEvent({
|
||||
callId: HARNESS_CALL_IDS.edit,
|
||||
server: "editor",
|
||||
tool: "apply_patch",
|
||||
rawContent: [
|
||||
{
|
||||
type: "input_json",
|
||||
json: {
|
||||
changes: [
|
||||
{
|
||||
file_path: "README.md",
|
||||
previous_content: "Old line\n",
|
||||
content: "New line\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
output: {
|
||||
changes: [
|
||||
{
|
||||
file_path: "README.md",
|
||||
previous_content: "Old line\n",
|
||||
content: "New line\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:02Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
callId: HARNESS_CALL_IDS.read,
|
||||
server: "editor",
|
||||
tool: "read_file",
|
||||
input: { file_path: "README.md" },
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:03Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolResultEvent({
|
||||
callId: HARNESS_CALL_IDS.read,
|
||||
server: "editor",
|
||||
tool: "read_file",
|
||||
rawContent: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "# README\nNew line\n",
|
||||
},
|
||||
],
|
||||
output: { content: "# README\nNew line\n" },
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:04Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
callId: HARNESS_CALL_IDS.command,
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
kind: "execute",
|
||||
input: { command: "ls" },
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:05Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolResultEvent({
|
||||
callId: HARNESS_CALL_IDS.command,
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
rawContent: [
|
||||
{
|
||||
type: "input_json",
|
||||
json: {
|
||||
result: {
|
||||
command: "ls",
|
||||
output: "README.md\npackages\n",
|
||||
},
|
||||
metadata: { exit_code: 0, cwd: "/tmp/harness" },
|
||||
},
|
||||
},
|
||||
],
|
||||
output: {
|
||||
result: {
|
||||
command: "ls",
|
||||
output: "README.md\npackages\n",
|
||||
},
|
||||
metadata: { exit_code: 0, cwd: "/tmp/harness" },
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:00:06Z"),
|
||||
},
|
||||
];
|
||||
|
||||
// Hydration snapshot recorded after refreshing the chat – this is the broken state we need to codify.
|
||||
const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Create a README snippet, show me the diff, and run ls.",
|
||||
messageId: "msg-live-user",
|
||||
},
|
||||
},
|
||||
timestamp: new Date("2025-02-01T10:05:00Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
callId: HARNESS_CALL_IDS.edit,
|
||||
server: "editor",
|
||||
tool: "apply_patch",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:05:01Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
callId: HARNESS_CALL_IDS.read,
|
||||
server: "editor",
|
||||
tool: "read_file",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:05:02Z"),
|
||||
},
|
||||
{
|
||||
event: buildToolStartEvent({
|
||||
callId: HARNESS_CALL_IDS.command,
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
kind: "execute",
|
||||
status: "completed",
|
||||
}),
|
||||
timestamp: new Date("2025-02-01T10:05:03Z"),
|
||||
},
|
||||
];
|
||||
|
||||
describe("stream harness captures hydrated regression", () => {
|
||||
it("records tool payloads during the live run", () => {
|
||||
const liveState = hydrateStreamState(STREAM_HARNESS_LIVE);
|
||||
const snapshots = extractHarnessSnapshots(liveState);
|
||||
|
||||
expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0);
|
||||
expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line");
|
||||
expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md");
|
||||
});
|
||||
|
||||
it("should hydrate tool payloads after a refresh", () => {
|
||||
const hydratedState = hydrateStreamState(STREAM_HARNESS_HYDRATED);
|
||||
const snapshots = extractHarnessSnapshots(hydratedState);
|
||||
|
||||
expect(snapshots.edit?.payload.data.parsedEdits?.[0]?.diffLines.length).toBeGreaterThan(0);
|
||||
expect(snapshots.read?.payload.data.parsedReads?.[0]?.content).toContain("New line");
|
||||
expect(snapshots.command?.payload.data.parsedCommand?.output).toContain("README.md");
|
||||
});
|
||||
});
|
||||
|
||||
function buildToolStartEvent({
|
||||
callId,
|
||||
server,
|
||||
tool,
|
||||
input,
|
||||
kind,
|
||||
status = "pending",
|
||||
}: {
|
||||
callId: string;
|
||||
server: string;
|
||||
tool: string;
|
||||
input?: Record<string, unknown>;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
}): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
callId,
|
||||
displayName: tool,
|
||||
kind,
|
||||
raw: input ? { type: "mcp_tool_use", id: callId, server, name: tool, input } : undefined,
|
||||
input,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolResultEvent({
|
||||
callId,
|
||||
server,
|
||||
tool,
|
||||
rawContent,
|
||||
output,
|
||||
}: {
|
||||
callId: string;
|
||||
server: string;
|
||||
tool: string;
|
||||
rawContent: Array<Record<string, unknown>>;
|
||||
output?: Record<string, unknown>;
|
||||
}): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
server,
|
||||
tool,
|
||||
callId,
|
||||
displayName: tool,
|
||||
raw: {
|
||||
type: "mcp_tool_result",
|
||||
tool_use_id: callId,
|
||||
server,
|
||||
tool_name: tool,
|
||||
content: rawContent,
|
||||
},
|
||||
output,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractHarnessSnapshots(state: StreamItem[]): Record<keyof typeof HARNESS_CALL_IDS, ToolCallItem | undefined> {
|
||||
const lookup = Object.values(HARNESS_CALL_IDS).reduce<Record<string, ToolCallItem | undefined>>(
|
||||
(acc, id) => {
|
||||
acc[id] = findToolByCallId(state, id);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return {
|
||||
command: lookup[HARNESS_CALL_IDS.command],
|
||||
edit: lookup[HARNESS_CALL_IDS.edit],
|
||||
read: lookup[HARNESS_CALL_IDS.read],
|
||||
};
|
||||
}
|
||||
|
||||
function findToolByCallId(state: StreamItem[], callId: string): ToolCallItem | undefined {
|
||||
return state.find(
|
||||
(item): item is ToolCallItem =>
|
||||
item.kind === "tool_call" &&
|
||||
item.payload.source === "agent" &&
|
||||
item.payload.data.callId === callId
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user