Repository navigation
Expand file tree
/
Copy pathenter-spec-mode.ts
More file actions
118 lines (108 loc) · 3.57 KB
/
Copy pathenter-spec-mode.ts
File metadata and controls
118 lines (108 loc) · 3.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/**
* Enter spec mode on a live session.
*
* The other spec-mode examples start the session in spec mode. This one starts
* in Auto mode and switches mid-session with `enterSpecMode()`, which is what a
* host app does when the user toggles spec mode on an open conversation.
*
* The permission handler prints the proposed plan (`ExitSpecMode`), approves
* it and the write to the output file, and cancels any other tool call.
*
* Usage:
* npx tsx examples/node/enter-spec-mode.ts
*/
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
type ClientPermissionHandler,
type ToolConfirmationDetails,
DroidInteractionMode,
DroidMessageType,
ToolConfirmationOutcome,
ToolConfirmationType,
createSession,
} from '@factory/droid-sdk/node';
// Resolve symlinks (for example /var -> /private/var on macOS) so the path in
// the prompt is the same path that the permission request reports.
const tempDir = await realpath(
await mkdtemp(join(tmpdir(), 'droid-sdk-enter-spec-'))
);
const outputPath = join(tempDir, 'hello.txt');
/** True when a tool call only writes the file that the prompt asks for. */
function writesOnlyOutputFile(details: ToolConfirmationDetails): boolean {
switch (details.type) {
case ToolConfirmationType.Create:
case ToolConfirmationType.Edit:
return details.filePath === outputPath;
case ToolConfirmationType.ApplyPatch:
return (
details.files !== undefined &&
details.files.length > 0 &&
details.files.every(
(file) =>
file.filePath === outputPath &&
file.operation !== 'delete' &&
file.moveTo === undefined
)
);
default:
return false;
}
}
const handlePermissionRequest: ClientPermissionHandler = ({
toolUses,
options,
}) => {
// A Script request batches tool calls; see permission-handler.ts.
const confirmations = toolUses.flatMap((toolUse) =>
toolUse.details.type === ToolConfirmationType.Script
? toolUse.details.calls.map((call) => call.confirmation)
: [toolUse.details]
);
for (const details of confirmations) {
if (details.type === ToolConfirmationType.ExitSpecMode) {
console.log(details.plan);
}
}
// Approve the plan and the write to outputPath. Cancel everything else.
const expected =
confirmations.length > 0 &&
confirmations.every(
(details) =>
details.type === ToolConfirmationType.ExitSpecMode ||
writesOnlyOutputFile(details)
);
const outcome = expected
? ToolConfirmationOutcome.ProceedOnce
: ToolConfirmationOutcome.Cancel;
if (!options.some((option) => option.value === outcome)) {
throw new Error(`Outcome not offered: ${outcome}`);
}
return outcome;
};
try {
const session = await createSession({
cwd: tempDir,
interactionMode: DroidInteractionMode.Auto,
permissionHandler: handlePermissionRequest,
});
try {
await session.enterSpecMode();
// The handler cancels shell commands, so the prompt asks for a file tool.
for await (const message of session.stream(
`Create ${outputPath} containing "hello". Write it with a file editing tool, not a shell command.`
)) {
if (message.type === DroidMessageType.Result && !message.success) {
throw new Error(
message.error?.message ?? `Turn ended: ${message.subtype}`
);
}
}
} finally {
await session.close();
}
console.log(await readFile(outputPath, 'utf8'));
} finally {
await rm(tempDir, { recursive: true, force: true });
}