Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc815fc15f | ||
|
|
3b40b4020d | ||
|
|
f42b776b8e | ||
|
|
2f94b72ee1 | ||
|
|
8cf221e13f | ||
|
|
e4d4ec3aae | ||
|
|
275cd5a493 |
@@ -4,3 +4,4 @@ package-lock.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
.reference/
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Pi Coding Agent 0.80+
|
- Pi Coding Agent 0.86+
|
||||||
- A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`)
|
- A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`)
|
||||||
- Node 18+
|
- Node 22.19+ (required by Pi 0.86)
|
||||||
|
|
||||||
The CLI binary is resolved in this order:
|
The CLI binary is resolved in this order:
|
||||||
|
|
||||||
@@ -35,12 +35,16 @@ From git:
|
|||||||
pi install git:github.com/kashyab12/pi-devin
|
pi install git:github.com/kashyab12/pi-devin
|
||||||
```
|
```
|
||||||
|
|
||||||
After npm publish:
|
From npm:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pi install npm:pi-devin
|
pi install npm:pi-devin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Version 0.2.0 requires Pi 0.86+ and Node 22.19+. It restores system instructions and tools on the current Pi transcript format and sends system instructions through Devin's dedicated prompt field. Version 0.1.2 predates Pi 0.86 support.
|
||||||
|
|
||||||
|
`pi-devin-local` is a separate npm package maintained in the [mizorewww/pi-devin fork](https://github.com/mizorewww/pi-devin). Changes merged here do not update that package. Install only one: both packages register the `devin` provider, so their registrations can overwrite each other.
|
||||||
|
|
||||||
Local checkout:
|
Local checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-devin",
|
"name": "pi-devin",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.",
|
"description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -29,17 +29,19 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "tsx --test test/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@earendil-works/pi-ai": "*",
|
"@earendil-works/pi-ai": ">=0.86.0",
|
||||||
"@earendil-works/pi-coding-agent": "*"
|
"@earendil-works/pi-coding-agent": ">=0.86.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -1,4 +1,12 @@
|
|||||||
import type { Context, Message, Tool } from "@earendil-works/pi-ai";
|
import {
|
||||||
|
collapseSystemMessages,
|
||||||
|
getCurrentSystemPrompt,
|
||||||
|
getCurrentTools,
|
||||||
|
withoutInitialSystemMessage,
|
||||||
|
type Message,
|
||||||
|
type Tool,
|
||||||
|
type TranscriptContext,
|
||||||
|
} from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
export interface ContentPart {
|
export interface ContentPart {
|
||||||
type: "text" | "image";
|
type: "text" | "image";
|
||||||
@@ -8,7 +16,7 @@ export interface ContentPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatHistoryItem {
|
export interface ChatHistoryItem {
|
||||||
role: "user" | "assistant" | "system" | "tool";
|
role: "user" | "assistant" | "tool";
|
||||||
content: string | ContentPart[];
|
content: string | ContentPart[];
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
||||||
@@ -21,6 +29,8 @@ export interface ToolDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MappedChat {
|
export interface MappedChat {
|
||||||
|
/** Sent in GetChatMessageRequest.prompt, separately from conversation history. */
|
||||||
|
systemPrompt?: string;
|
||||||
messages: ChatHistoryItem[];
|
messages: ChatHistoryItem[];
|
||||||
tools: ToolDef[];
|
tools: ToolDef[];
|
||||||
}
|
}
|
||||||
@@ -39,13 +49,12 @@ function userContent(content: Message["content"]): string | ContentPart[] {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapContextToChat(context: Context): MappedChat {
|
export function mapContextToChat(context: TranscriptContext): MappedChat {
|
||||||
|
// Fold later system updates into the single prompt sent separately to Devin.
|
||||||
|
const transcript = collapseSystemMessages(context);
|
||||||
|
const systemPrompt = getCurrentSystemPrompt(transcript.messages);
|
||||||
const messages: ChatHistoryItem[] = [];
|
const messages: ChatHistoryItem[] = [];
|
||||||
if (context.systemPrompt) {
|
for (const message of withoutInitialSystemMessage(transcript.messages)) {
|
||||||
messages.push({ role: "system", content: context.systemPrompt });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const message of context.messages) {
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
messages.push({ role: "user", content: userContent(message.content) });
|
messages.push({ role: "user", content: userContent(message.content) });
|
||||||
continue;
|
continue;
|
||||||
@@ -86,11 +95,11 @@ export function mapContextToChat(context: Context): MappedChat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools: ToolDef[] = (context.tools ?? []).map((tool: Tool) => ({
|
const tools: ToolDef[] = getCurrentTools(transcript.messages).map((tool: Tool) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: tool.parameters,
|
parameters: tool.parameters,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { messages, tools };
|
return { systemPrompt: systemPrompt || undefined, messages, tools };
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -7,10 +7,11 @@ import {
|
|||||||
} from "./wire.js";
|
} from "./wire.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cognition version-gates GetChatMessage against a known Windsurf/Devin Desktop
|
* Cognition gates Devin Local-only models (every GPT-5.6 variant: Sol, Terra,
|
||||||
* release. The Devin CLI build id (`3000.4.25`) is not that string and the
|
* Luna) by client ide name. With ide="windsurf" GetChatMessage rejects them
|
||||||
* server answers: "Your Windsurf version is out of date."
|
* with "This model is only in Devin Local."; with ide="devin-desktop" the
|
||||||
* Prefer the installed Devin.app product.json, then a current desktop release.
|
* server serves them and the response header echoes the exact model
|
||||||
|
* (verified: "GPT-5.6 Sol High Thinking" for gpt-5-6-sol-high, 2026-08-29).
|
||||||
*/
|
*/
|
||||||
const FALLBACK_WINDSURF_VERSION = "3.6.27";
|
const FALLBACK_WINDSURF_VERSION = "3.6.27";
|
||||||
const PRODUCT_JSON =
|
const PRODUCT_JSON =
|
||||||
@@ -29,7 +30,7 @@ function desktopWindsurfVersion(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CLIENT_VERSION = desktopWindsurfVersion();
|
export const CLIENT_VERSION = desktopWindsurfVersion();
|
||||||
export const CLIENT_IDE = "windsurf";
|
export const CLIENT_IDE = "devin-desktop";
|
||||||
|
|
||||||
export interface MetadataInput {
|
export interface MetadataInput {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|||||||
+10
-40
@@ -4,9 +4,9 @@ import {
|
|||||||
type Api,
|
type Api,
|
||||||
type AssistantMessage,
|
type AssistantMessage,
|
||||||
type AssistantMessageEventStream,
|
type AssistantMessageEventStream,
|
||||||
type Context,
|
|
||||||
type Model,
|
type Model,
|
||||||
type SimpleStreamOptions,
|
type SimpleStreamOptions,
|
||||||
|
type TranscriptContext,
|
||||||
calculateCost,
|
calculateCost,
|
||||||
createAssistantMessageEventStream,
|
createAssistantMessageEventStream,
|
||||||
} from "@earendil-works/pi-ai";
|
} from "@earendil-works/pi-ai";
|
||||||
@@ -23,10 +23,9 @@ import {
|
|||||||
iterFields,
|
iterFields,
|
||||||
} from "./wire.js";
|
} from "./wire.js";
|
||||||
|
|
||||||
const SOURCE_BY_ROLE: Record<string, number> = {
|
const SOURCE_BY_ROLE: Record<ChatHistoryItem["role"], number> = {
|
||||||
user: 1,
|
user: 1,
|
||||||
assistant: 2,
|
assistant: 2,
|
||||||
system: 1,
|
|
||||||
tool: 4,
|
tool: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,40 +49,6 @@ function normalizeContent(content: string | ContentPart[]): ContentPart[] {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] {
|
|
||||||
const out: ChatHistoryItem[] = [];
|
|
||||||
let pending: string[] = [];
|
|
||||||
const textOf = (content: string | ContentPart[]) =>
|
|
||||||
normalizeContent(content)
|
|
||||||
.filter((part) => part.type === "text" && part.text)
|
|
||||||
.map((part) => part.text as string)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message.role === "system") {
|
|
||||||
const text = textOf(message.content);
|
|
||||||
if (text) pending.push(text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (message.role === "user" && pending.length > 0) {
|
|
||||||
const userParts = normalizeContent(message.content);
|
|
||||||
const userText = textOf(userParts);
|
|
||||||
const images = userParts.filter((part) => part.type === "image");
|
|
||||||
out.push({
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "text", text: `<system>\n${pending.join("\n\n")}\n</system>\n${userText}` }, ...images],
|
|
||||||
});
|
|
||||||
pending = [];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(message);
|
|
||||||
}
|
|
||||||
if (pending.length > 0) {
|
|
||||||
out.push({ role: "user", content: `<system>\n${pending.join("\n\n")}\n</system>` });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
|
function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
encodeString(1, img.base64Data ?? ""),
|
encodeString(1, img.base64Data ?? ""),
|
||||||
@@ -144,6 +109,7 @@ function buildGetChatMessageRequest(args: {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
userJwt: string;
|
userJwt: string;
|
||||||
modelUid: string;
|
modelUid: string;
|
||||||
|
systemPrompt?: string;
|
||||||
messages: ChatHistoryItem[];
|
messages: ChatHistoryItem[];
|
||||||
tools?: ToolDef[];
|
tools?: ToolDef[];
|
||||||
cascadeId: string;
|
cascadeId: string;
|
||||||
@@ -160,10 +126,10 @@ function buildGetChatMessageRequest(args: {
|
|||||||
requestId: args.requestId,
|
requestId: args.requestId,
|
||||||
triggerId: args.triggerId,
|
triggerId: args.triggerId,
|
||||||
});
|
});
|
||||||
const prompts = collapseSystemIntoUser(args.messages).map((message) =>
|
const prompts = args.messages.map((message) =>
|
||||||
encodeMessage(
|
encodeMessage(
|
||||||
3,
|
3,
|
||||||
encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role] ?? 1, {
|
encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role], {
|
||||||
toolCallId: message.role === "tool" ? message.tool_call_id : undefined,
|
toolCallId: message.role === "tool" ? message.tool_call_id : undefined,
|
||||||
toolCalls: message.role === "assistant" ? message.tool_calls : undefined,
|
toolCalls: message.role === "assistant" ? message.tool_calls : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -171,6 +137,7 @@ function buildGetChatMessageRequest(args: {
|
|||||||
);
|
);
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
encodeMessage(1, metadata),
|
encodeMessage(1, metadata),
|
||||||
|
...(args.systemPrompt ? [encodeString(2, args.systemPrompt)] : []),
|
||||||
...prompts,
|
...prompts,
|
||||||
encodeVarintField(7, 5),
|
encodeVarintField(7, 5),
|
||||||
encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)),
|
encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)),
|
||||||
@@ -271,6 +238,7 @@ async function* streamChatEvents(args: {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
host: string;
|
host: string;
|
||||||
modelUid: string;
|
modelUid: string;
|
||||||
|
systemPrompt?: string;
|
||||||
messages: ChatHistoryItem[];
|
messages: ChatHistoryItem[];
|
||||||
tools?: ToolDef[];
|
tools?: ToolDef[];
|
||||||
maxOutputTokens?: number;
|
maxOutputTokens?: number;
|
||||||
@@ -283,6 +251,7 @@ async function* streamChatEvents(args: {
|
|||||||
apiKey: args.apiKey,
|
apiKey: args.apiKey,
|
||||||
userJwt,
|
userJwt,
|
||||||
modelUid: args.modelUid,
|
modelUid: args.modelUid,
|
||||||
|
systemPrompt: args.systemPrompt,
|
||||||
messages: args.messages,
|
messages: args.messages,
|
||||||
tools: args.tools,
|
tools: args.tools,
|
||||||
cascadeId: ids.cascadeId,
|
cascadeId: ids.cascadeId,
|
||||||
@@ -402,7 +371,7 @@ async function* streamChatEvents(args: {
|
|||||||
|
|
||||||
export function streamDevin(
|
export function streamDevin(
|
||||||
model: Model<Api>,
|
model: Model<Api>,
|
||||||
context: Context,
|
context: TranscriptContext,
|
||||||
options?: SimpleStreamOptions,
|
options?: SimpleStreamOptions,
|
||||||
): AssistantMessageEventStream {
|
): AssistantMessageEventStream {
|
||||||
const stream = createAssistantMessageEventStream();
|
const stream = createAssistantMessageEventStream();
|
||||||
@@ -482,6 +451,7 @@ export function streamDevin(
|
|||||||
apiKey,
|
apiKey,
|
||||||
host,
|
host,
|
||||||
modelUid,
|
modelUid,
|
||||||
|
systemPrompt: mapped.systemPrompt,
|
||||||
messages: mapped.messages,
|
messages: mapped.messages,
|
||||||
tools: mapped.tools.length > 0 ? mapped.tools : undefined,
|
tools: mapped.tools.length > 0 ? mapped.tools : undefined,
|
||||||
maxOutputTokens: options?.maxTokens,
|
maxOutputTokens: options?.maxTokens,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||||
|
import { mapContextToChat } from "../src/context-map.ts";
|
||||||
|
|
||||||
|
const tool = (name) => ({
|
||||||
|
name,
|
||||||
|
description: name,
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression for #7: Pi 0.86 moves prompt and tools into system messages.
|
||||||
|
test("preserves the prompt and tools from a normalized transcript", () => {
|
||||||
|
const read = tool("read");
|
||||||
|
const context = normalizeContext({
|
||||||
|
systemPrompt: "Inspect the repository.",
|
||||||
|
tools: [read],
|
||||||
|
messages: [{ role: "user", content: "Read README", timestamp: 1 }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(mapContextToChat(context), {
|
||||||
|
systemPrompt: "Inspect the repository.",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Read README" },
|
||||||
|
],
|
||||||
|
tools: [read],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies later instruction, section, and tool changes", () => {
|
||||||
|
const bash = tool("bash");
|
||||||
|
const context = {
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "Inspect the repository.", sections: { policy: "Old policy", obsolete: "Remove me" }, toolsAdded: [tool("read")], timestamp: 0 },
|
||||||
|
{ role: "user", content: "Continue", timestamp: 1 },
|
||||||
|
{ role: "system", content: "Use bash.", sections: { policy: "Do not edit.", obsolete: null }, toolsRemoved: [{ name: "read" }], toolsAdded: [bash], timestamp: 2 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(mapContextToChat(context), {
|
||||||
|
systemPrompt: "Inspect the repository.\n\nUse bash.\n\nDo not edit.",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Continue" },
|
||||||
|
],
|
||||||
|
tools: [bash],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts an empty transcript", () => {
|
||||||
|
assert.deepEqual(mapContextToChat(normalizeContext({ messages: [] })), {
|
||||||
|
systemPrompt: undefined,
|
||||||
|
messages: [],
|
||||||
|
tools: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { gunzipSync } from "node:zlib";
|
||||||
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||||
|
import { clearCachedUserJwt } from "../src/jwt.ts";
|
||||||
|
import { streamDevin } from "../src/stream.ts";
|
||||||
|
import { encodeMessage, encodeString, encodeVarintField, frameConnectStream, iterFields } from "../src/wire.ts";
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
id: "test-model",
|
||||||
|
name: "Test model",
|
||||||
|
api: "devin-local",
|
||||||
|
provider: "devin",
|
||||||
|
baseUrl: "https://devin.invalid",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 262_000,
|
||||||
|
maxTokens: 1_000,
|
||||||
|
};
|
||||||
|
const readTool = {
|
||||||
|
name: "read",
|
||||||
|
description: "Read a file",
|
||||||
|
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
||||||
|
};
|
||||||
|
const user = (content) => ({ role: "user", content, timestamp: 1 });
|
||||||
|
const fields = (buffer) => [...iterFields(buffer)];
|
||||||
|
const messages = (request) => request.filter((field) => field.num === 3).map((field) => fields(field.value));
|
||||||
|
const stringField = (fields, number) => fields.find((field) => field.num === number)?.value.toString("utf8");
|
||||||
|
|
||||||
|
function response(body) {
|
||||||
|
// Connect's end-of-stream frame contains the JSON trailer {}.
|
||||||
|
return new Response(Buffer.concat([frameConnectStream(body, false), Buffer.from([2, 0, 0, 0, 2, 123, 125])]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockDevin(t, replies = [Buffer.concat([encodeString(3, "OK"), encodeVarintField(5, 0)])]) {
|
||||||
|
const requests = [];
|
||||||
|
clearCachedUserJwt();
|
||||||
|
t.after(clearCachedUserJwt);
|
||||||
|
t.mock.method(globalThis, "fetch", async (url, options) => {
|
||||||
|
if (url === "https://devin.invalid/exa.auth_pb.AuthService/GetUserJwt") {
|
||||||
|
return new Response(encodeMessage(1, Buffer.from("eyJtest.jwt")));
|
||||||
|
}
|
||||||
|
assert.equal(url, "https://devin.invalid/exa.api_server_pb.ApiServerService/GetChatMessage");
|
||||||
|
const frame = Buffer.from(options.body);
|
||||||
|
assert.equal(frame[0], 1, "request uses Connect gzip compression");
|
||||||
|
assert.equal(frame.readUInt32BE(1), frame.length - 5);
|
||||||
|
requests.push(fields(gunzipSync(frame.subarray(5))));
|
||||||
|
const reply = replies[requests.length - 1];
|
||||||
|
assert.ok(reply, "unexpected extra chat request");
|
||||||
|
return response(reply);
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete(context) {
|
||||||
|
const stream = streamDevin(model, context, {
|
||||||
|
apiKey: "synthetic-test-key",
|
||||||
|
env: { DEVIN_API_SERVER_URL: "https://devin.invalid" },
|
||||||
|
});
|
||||||
|
for await (const event of stream) {
|
||||||
|
assert.notEqual(event.type, "error", event.error?.errorMessage);
|
||||||
|
}
|
||||||
|
return stream.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #7: verify the normalized transcript survives the complete request-encoding path.
|
||||||
|
test("encodes the current system prompt once in field 2, with unchanged user text and images", async (t) => {
|
||||||
|
const requests = mockDevin(t);
|
||||||
|
const text = " Inspect this: <system>literal user text</system>\nこんにちは ";
|
||||||
|
const image = { type: "image", mimeType: "image/png", data: "aW1hZ2UtZml4dHVyZQ==" };
|
||||||
|
const context = normalizeContext({
|
||||||
|
systemPrompt: "Initial instructions.",
|
||||||
|
tools: [readTool],
|
||||||
|
messages: [user([{ type: "text", text }, image])],
|
||||||
|
});
|
||||||
|
const tools = Array.from({ length: 90 }, (_, i) => ({ ...readTool, name: `read_${i}` }));
|
||||||
|
context.messages.push({
|
||||||
|
role: "system",
|
||||||
|
content: "Updated instructions.",
|
||||||
|
sections: { policy: "Do not edit." },
|
||||||
|
toolsRemoved: [{ name: "read" }],
|
||||||
|
toolsAdded: tools,
|
||||||
|
timestamp: 2,
|
||||||
|
});
|
||||||
|
await complete(context);
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
const request = requests[0];
|
||||||
|
const promptFields = request.filter((field) => field.num === 2);
|
||||||
|
assert.equal(promptFields.length, 1);
|
||||||
|
assert.equal(promptFields[0].wire, 2);
|
||||||
|
assert.equal(promptFields[0].value.toString(), "Initial instructions.\n\nUpdated instructions.\n\nDo not edit.");
|
||||||
|
const history = messages(request);
|
||||||
|
assert.equal(history.length, 1);
|
||||||
|
assert.equal(history[0].find((field) => field.num === 2).value, 1n);
|
||||||
|
assert.equal(stringField(history[0], 3), text);
|
||||||
|
const images = history[0].filter((field) => field.num === 10);
|
||||||
|
assert.equal(images.length, 1);
|
||||||
|
assert.equal(stringField(fields(images[0].value), 1), image.data);
|
||||||
|
assert.equal(stringField(fields(images[0].value), 2), image.mimeType);
|
||||||
|
assert.deepEqual(request.filter((field) => field.num === 10).map((field) => {
|
||||||
|
const definition = fields(field.value);
|
||||||
|
return { name: stringField(definition, 1), description: stringField(definition, 2), parameters: JSON.parse(stringField(definition, 3)) };
|
||||||
|
}), tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits an empty system prompt while preserving tools and user content", async (t) => {
|
||||||
|
const requests = mockDevin(t);
|
||||||
|
await complete(normalizeContext({ systemPrompt: "", tools: [readTool], messages: [user("Read probe.txt")] }));
|
||||||
|
assert.equal(requests[0].filter((field) => field.num === 2).length, 0);
|
||||||
|
assert.equal(requests[0].filter((field) => field.num === 10).length, 1);
|
||||||
|
assert.deepEqual(messages(requests[0]).map((message) => stringField(message, 3)), ["Read probe.txt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends a system-only transcript without inventing a user message", async (t) => {
|
||||||
|
const requests = mockDevin(t);
|
||||||
|
await complete(normalizeContext({ systemPrompt: "System-only instructions.", messages: [] }));
|
||||||
|
assert.equal(stringField(requests[0], 2), "System-only instructions.");
|
||||||
|
assert.deepEqual(messages(requests[0]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodes an empty transcript without a prompt or history", async (t) => {
|
||||||
|
const requests = mockDevin(t);
|
||||||
|
await complete(normalizeContext({ messages: [] }));
|
||||||
|
assert.equal(stringField(requests[0], 2), undefined);
|
||||||
|
assert.deepEqual(messages(requests[0]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains the system prompt and replays assistant tool calls and results on the next request", async (t) => {
|
||||||
|
const callId = "call-read";
|
||||||
|
const args = { path: "probe.txt" };
|
||||||
|
const requests = mockDevin(t, [
|
||||||
|
Buffer.concat([
|
||||||
|
encodeMessage(6, Buffer.concat([encodeString(1, callId), encodeString(2, "read"), encodeString(3, JSON.stringify(args))])),
|
||||||
|
encodeVarintField(5, 10),
|
||||||
|
]),
|
||||||
|
Buffer.concat([encodeString(3, "file-value"), encodeVarintField(5, 0)]),
|
||||||
|
]);
|
||||||
|
const context = normalizeContext({ systemPrompt: "Use tools; never guess.", tools: [readTool], messages: [user("Read probe.txt")] });
|
||||||
|
const first = await complete(context);
|
||||||
|
assert.equal(first.stopReason, "toolUse");
|
||||||
|
assert.deepEqual(first.content, [{ type: "toolCall", id: callId, name: "read", arguments: args }]);
|
||||||
|
context.messages.push(first, {
|
||||||
|
role: "toolResult", toolCallId: callId, toolName: "read",
|
||||||
|
content: [{ type: "text", text: "file-value" }], isError: false, timestamp: 3,
|
||||||
|
});
|
||||||
|
const second = await complete(context);
|
||||||
|
assert.equal(second.stopReason, "stop");
|
||||||
|
assert.equal(second.content[0].text, "file-value");
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
for (const request of requests) {
|
||||||
|
assert.equal(request.filter((field) => field.num === 2).length, 1);
|
||||||
|
assert.equal(stringField(request, 2), "Use tools; never guess.");
|
||||||
|
}
|
||||||
|
const history = messages(requests[1]);
|
||||||
|
assert.deepEqual(history.map((message) => message.find((field) => field.num === 2).value), [1n, 2n, 4n]);
|
||||||
|
assert.equal(stringField(history[0], 3), "Read probe.txt");
|
||||||
|
const call = fields(history[1].find((field) => field.num === 6).value);
|
||||||
|
assert.equal(stringField(call, 1), callId);
|
||||||
|
assert.equal(stringField(call, 2), "read");
|
||||||
|
assert.deepEqual(JSON.parse(stringField(call, 3)), args);
|
||||||
|
assert.equal(stringField(history[2], 7), callId);
|
||||||
|
assert.equal(stringField(history[2], 3), "file-value");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user