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
52 changes: 47 additions & 5 deletions electron/agent-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export function registerAgentHookIpc() {
if (!pending || pending.webContentsId !== event.sender.id) {
return { status: "ignored" };
}
const result = normalizeAgentHookListenerResult(payload?.result);
clearTimeout(pending.timeout);
pendingDeliveries.delete(deliveryId);
pending.resolve({
status: payload?.status === "error" ? "error" : "ok",
...result,
});
return { status: "acknowledged" };
});
Expand Down Expand Up @@ -270,6 +272,9 @@ async function processAgentHookMessage(endpoint, input) {
return {
ok: delivery.status !== "timeout" && delivery.status !== "disposed",
status: delivery.status,
...(delivery.additionalContext
? { additionalContext: delivery.additionalContext }
: {}),
};
}

Expand Down Expand Up @@ -359,6 +364,27 @@ function parseHookStdin(value) {
}
}

function normalizeAgentHookListenerResult(value) {
if (typeof value === "string") {
return normalizeAgentHookAdditionalContext(value);
}
if (!value || typeof value !== "object") {
return {};
}
return normalizeAgentHookAdditionalContext(value.additionalContext);
}

function normalizeAgentHookAdditionalContext(value) {
if (typeof value !== "string") {
return {};
}
const additionalContext = value.trim();
if (!additionalContext) {
return {};
}
return { additionalContext };
}

export function agentHookScriptSource() {
return String.raw`import { readFileSync } from "node:fs";
import { connect } from "node:net";
Expand All @@ -383,7 +409,15 @@ const payload = {
};

try {
await sendHookEvent(payload);
const response = await sendHookEvent(payload);
const additionalContext = readAdditionalContext(response);
if (payload.phase === "turn-start" && additionalContext) {
process.stdout.write(
additionalContext.endsWith("\n")
? additionalContext
: additionalContext + "\n",
);
}
} catch {
// Hook delivery must not prevent the agent command from continuing.
}
Expand All @@ -393,15 +427,15 @@ function sendHookEvent(payload) {
const socket = connect(socketPath);
let response = "";
let settled = false;
const finish = (error) => {
const finish = (error, value) => {
if (settled) return;
settled = true;
socket.destroy();
if (error) {
reject(error);
return;
}
resolve();
resolve(value);
};

socket.setEncoding("utf8");
Expand All @@ -419,13 +453,14 @@ function sendHookEvent(payload) {
finish(new Error("Hook acknowledgement was empty"));
return;
}
let parsed;
try {
JSON.parse(response);
parsed = JSON.parse(response);
} catch {
finish(new Error("Hook acknowledgement was invalid JSON"));
return;
}
finish();
finish(null, parsed);
});
socket.on("error", finish);
socket.on("close", () => {
Expand All @@ -434,5 +469,12 @@ function sendHookEvent(payload) {
});
}

function readAdditionalContext(response) {
const value = response?.additionalContext;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}

`;
}
51 changes: 50 additions & 1 deletion electron/agent-hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,55 @@ describe("agentHookScriptSource", () => {
}
});

test("prints additional context from turn-start acknowledgements", async () => {
const rootDir = await mkdtemp(path.join(tmpdir(), "flashtype-agent-hook-"));
const socketPath = testSocketPath(rootDir);
const received = deferred();
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
let input = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
input += chunk;
});
socket.on("end", () => {
received.resolve(JSON.parse(input));
socket.end(
`${JSON.stringify({
ok: true,
additionalContext: "The active file right now is ./current.md",
})}\n`,
);
});
});
try {
const scriptPath = path.join(rootDir, "hook.mjs");
await writeFile(scriptPath, agentHookScriptSource(), { mode: 0o700 });
await listen(server, socketPath);

const result = await runHookScript({
scriptPath,
args: ["codex", "turn-start"],
env: {
FLASHTYPE_AGENT_HOOK_SOCKET: socketPath,
FLASHTYPE_AGENT_HOOK_TOKEN: "secret",
FLASHTYPE_AGENT_HOOK_INSTANCE_ID: "terminal:1",
},
stdin: JSON.stringify({
hook_event_name: "UserPromptSubmit",
}),
});

expect(await received.promise).toMatchObject({
agent: "codex",
phase: "turn-start",
});
expect(result.stdout).toBe("The active file right now is ./current.md\n");
} finally {
server.close();
await rm(rootDir, { recursive: true, force: true });
}
});

test("exits successfully when socket delivery fails", async () => {
const rootDir = await mkdtemp(path.join(tmpdir(), "flashtype-agent-hook-"));
try {
Expand Down Expand Up @@ -280,7 +329,7 @@ function runHookScript({ scriptPath, args, env, stdin }) {
child.on("error", reject);
child.on("close", (code, signal) => {
if (code === 0) {
resolve();
resolve({ stdout, stderr });
return;
}
reject(
Expand Down
17 changes: 16 additions & 1 deletion electron/preload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ const agentHooks = {
return;
}
void Promise.resolve(listener(payload.event))
.then(() =>
.then((result) =>
ipcRenderer.invoke("agentHooks:completeTurnEvent", {
deliveryId: payload.deliveryId,
result: normalizeAgentHookListenerResult(result),
status: "ok",
}),
)
Expand All @@ -140,6 +141,20 @@ const agentHooks = {
},
};

function normalizeAgentHookListenerResult(value) {
if (typeof value === "string") {
return { additionalContext: value };
}
if (!value || typeof value !== "object") {
return undefined;
}
const additionalContext = value.additionalContext;
if (typeof additionalContext !== "string") {
return undefined;
}
return { additionalContext };
}

contextBridge.exposeInMainWorld("flashtypeDesktop", {
agentHooks,
app,
Expand Down
11 changes: 10 additions & 1 deletion electron/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,18 @@ export type DesktopAgentTurnEvent = {
createdAt: number;
};

export type DesktopAgentTurnEventResult =
| void
| string
| {
additionalContext?: string | null;
};

export type DesktopAgentHooksApi = {
onTurnEvent(
listener: (event: DesktopAgentTurnEvent) => void | Promise<void>,
listener: (
event: DesktopAgentTurnEvent,
) => DesktopAgentTurnEventResult | Promise<DesktopAgentTurnEventResult>,
): () => void;
};

Expand Down
19 changes: 12 additions & 7 deletions src/shell/agent-launch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
FLASHTYPE_INITIAL_PROMPT,
TERMINAL_INITIAL_COMMAND_LAUNCH_ARG,
buildAgentLaunchArgsWithActiveFile,
buildFlashtypeActiveFilePrompt,
Expand All @@ -11,9 +12,9 @@ import {
} from "@/extension-runtime/agent-terminal-command";

describe("buildFlashtypeActiveFilePrompt", () => {
test("uses the Flashtype.com launch-context sentence", () => {
test("uses the active-file context sentence", () => {
expect(buildFlashtypeActiveFilePrompt("/docs/intro.md")).toBe(
"The user is using Flashtype.com. The active file right now, which may change later, is: ./docs/intro.md",
"The current document is: ./docs/intro.md",
);
});

Expand All @@ -29,7 +30,7 @@ describe("buildFlashtypeActiveFilePrompt", () => {
});

describe("buildAgentLaunchArgsWithActiveFile", () => {
test("adds a Claude append-system-prompt launch command", () => {
test("adds Claude hook launch args with Flashtype system prompt only", () => {
const launchArgs = buildAgentLaunchArgsWithActiveFile({
state: {
command: "claude --dangerously-skip-permissions",
Expand All @@ -56,11 +57,12 @@ describe("buildAgentLaunchArgsWithActiveFile", () => {
'ELECTRON_RUN_AS_NODE=1 \\"$FLASHTYPE_AGENT_HOOK_NODE\\" \\"$FLASHTYPE_AGENT_HOOK_SCRIPT\\" claude turn-start',
);
expect(pathWrapper.command).toContain(
"--append-system-prompt 'The user is using Flashtype.com. The active file right now, which may change later, is: ./docs/intro.md'",
`--append-system-prompt '${FLASHTYPE_INITIAL_PROMPT}'`,
);
expect(pathWrapper.command).not.toContain("./docs/intro.md");
});

test("adds a Codex developer-instructions launch command", () => {
test("adds Codex hook launch args with Flashtype developer instructions only", () => {
const launchArgs = buildAgentLaunchArgsWithActiveFile({
state: {
command: "codex --dangerously-bypass-approvals-and-sandbox",
Expand All @@ -86,8 +88,9 @@ describe("buildAgentLaunchArgsWithActiveFile", () => {
'ELECTRON_RUN_AS_NODE=1 \\"$FLASHTYPE_AGENT_HOOK_NODE\\" \\"$FLASHTYPE_AGENT_HOOK_SCRIPT\\" codex turn-start',
);
expect(pathWrapper.command).toContain(
"-c 'developer_instructions=\"The user is using Flashtype.com. The active file right now, which may change later, is: ./docs/intro.md\"'",
`-c 'developer_instructions="${FLASHTYPE_INITIAL_PROMPT}"'`,
);
expect(pathWrapper.command).not.toContain("./docs/intro.md");
});

test("injects hooks for agent launches without an active file", () => {
Expand All @@ -105,7 +108,9 @@ describe("buildAgentLaunchArgsWithActiveFile", () => {
};
expect(command).toBe("codex-flashtype");
expect(pathWrapper.command).toContain("hooks.UserPromptSubmit=");
expect(pathWrapper.command).not.toContain("developer_instructions=");
expect(pathWrapper.command).toContain(
`developer_instructions="${FLASHTYPE_INITIAL_PROMPT}"`,
);
});

test("injects hooks into restored agent terminal commands", () => {
Expand Down
11 changes: 8 additions & 3 deletions src/shell/agent-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ import {

export { TERMINAL_INITIAL_COMMAND_LAUNCH_ARG };

export const FLASHTYPE_INITIAL_PROMPT =
"You are running inside Flashtype, a local Markdown editor with inline diff review. Use workspace files as the source of truth. Make requested changes; the user reviews diffs before they land.";
Comment thread
alecmocatta marked this conversation as resolved.

export function buildAgentLaunchArgsWithActiveFile(args: {
readonly state?: ExtensionState;
readonly activeFilePath?: string | null;
}): ExtensionLaunchArgs | undefined {
const prompt = buildFlashtypeActiveFilePrompt(args.activeFilePath);
return buildAgentTerminalLaunchArgs({ state: args.state, prompt });
return buildAgentTerminalLaunchArgs({
state: args.state,
prompt: FLASHTYPE_INITIAL_PROMPT,
});
}

export function buildFlashtypeActiveFilePrompt(
Expand All @@ -24,7 +29,7 @@ export function buildFlashtypeActiveFilePrompt(
if (!promptPath) {
return null;
}
return `The user is using Flashtype.com. The active file right now, which may change later, is: ${promptPath}`;
return `The current document is: ${promptPath}`;
}

function normalizePromptFilePath(
Expand Down
34 changes: 30 additions & 4 deletions src/shell/layout-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type DesktopMock = {
};

type AgentHooksDesktopMock = {
readonly emitTurnEvent: (event: AgentHookTurnEventInput) => Promise<void>;
readonly emitTurnEvent: (event: AgentHookTurnEventInput) => Promise<unknown>;
readonly onTurnEvent: ReturnType<typeof vi.fn>;
readonly setActiveFilePath: ReturnType<typeof vi.fn>;
};
Expand Down Expand Up @@ -399,6 +399,32 @@ describe("V2LayoutShell active file sidebar highlight", () => {
});

describe("V2LayoutShell agent review auto-open", () => {
test("returns current active file context from turn-start hooks", async () => {
const desktop = installAgentHooksDesktopMock();
const lix = await openLix({
keyValues: [
uiStateKeyValue(openFileState("file_current", "/current.md")),
],
});
vi.spyOn(lix, "syncDiskToLix").mockResolvedValue();
await writeReviewFile(lix, "file_current", "/current.md", "# Current");

const utils = await renderShell(lix);
await waitFor(() => expect(desktop.onTurnEvent).toHaveBeenCalled());

let result: unknown;
await act(async () => {
result = await desktop.emitTurnEvent(agentTurnEvent("turn-start"));
});

expect(result).toEqual({
additionalContext: "The current document is: ./current.md",
});

await unmountShell(utils);
await lix.close();
});

test("leaves the current file open when it already has a pending review", async () => {
const desktop = installAgentHooksDesktopMock();
const lix = await openLix({
Expand Down Expand Up @@ -550,9 +576,9 @@ function installDesktopMock(): DesktopMock {
}

function installAgentHooksDesktopMock(): AgentHooksDesktopMock {
let listener: ((event: unknown) => void | Promise<void>) | null = null;
let listener: ((event: unknown) => unknown | Promise<unknown>) | null = null;
const onTurnEvent = vi.fn(
(nextListener: (event: unknown) => void | Promise<void>) => {
(nextListener: (event: unknown) => unknown | Promise<unknown>) => {
listener = nextListener;
return () => {
if (listener === nextListener) {
Expand All @@ -576,7 +602,7 @@ function installAgentHooksDesktopMock(): AgentHooksDesktopMock {
if (!listener) {
throw new Error("agent hook listener was not registered");
}
await listener(event);
return await listener(event);
},
onTurnEvent,
setActiveFilePath,
Expand Down
Loading
Loading