mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: Bedrock ARN region routing, Copilot device-flow pacing, TUI input fixes (#109740)
* build(deps): bump terminal UI library to 0.80.9 * fix(plugins): Bedrock ARN region routing, stop-reason detail, and Copilot device-flow pacing
This commit is contained in:
committed by
GitHub
parent
2bf07bdea5
commit
e693d279b3
@@ -1,5 +1,9 @@
|
||||
// Amazon Bedrock tests cover stream plugin behavior.
|
||||
import { BedrockRuntimeClient, ConversationRole } from "@aws-sdk/client-bedrock-runtime";
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConversationRole,
|
||||
StopReason as BedrockStopReason,
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import { onLlmRequestActivity } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
|
||||
@@ -48,7 +52,30 @@ async function* streamEvents(events: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function captureClientRegion(
|
||||
model: Parameters<typeof streamBedrock>[0],
|
||||
options: Parameters<typeof streamBedrock>[2] = {},
|
||||
): Promise<string> {
|
||||
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
stream: streamEvents([
|
||||
{ messageStart: { role: ConversationRole.ASSISTANT } },
|
||||
{ messageStop: { stopReason: BedrockStopReason.END_TURN } },
|
||||
]),
|
||||
} as never);
|
||||
|
||||
await streamBedrock(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 0 }] } as never,
|
||||
options,
|
||||
).result();
|
||||
|
||||
const client = send.mock.contexts[0] as BedrockRuntimeClient;
|
||||
return client.config.region();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -203,6 +230,71 @@ describe("Bedrock profile endpoint resolution", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "plain model id",
|
||||
modelId: "amazon.nova-micro-v1:0",
|
||||
ambientRegion: "eu-west-1",
|
||||
expectedRegion: "eu-west-1",
|
||||
},
|
||||
{
|
||||
name: "application inference-profile ARN",
|
||||
modelId: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/profile-abc",
|
||||
ambientRegion: "us-east-1",
|
||||
expectedRegion: "us-west-2",
|
||||
},
|
||||
{
|
||||
name: "GovCloud inference-profile ARN",
|
||||
modelId:
|
||||
"arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/profile-abc",
|
||||
ambientRegion: "us-east-1",
|
||||
expectedRegion: "us-gov-west-1",
|
||||
},
|
||||
{
|
||||
name: "ARN with explicit region option",
|
||||
modelId: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/profile-abc",
|
||||
ambientRegion: "us-east-1",
|
||||
explicitRegion: "ap-southeast-2",
|
||||
expectedRegion: "ap-southeast-2",
|
||||
},
|
||||
])(
|
||||
"resolves $name to $expectedRegion",
|
||||
async ({ modelId, ambientRegion, explicitRegion, expectedRegion }) => {
|
||||
vi.stubEnv("AWS_REGION", ambientRegion);
|
||||
|
||||
await expect(
|
||||
captureClientRegion(
|
||||
bedrockModel({ id: modelId }),
|
||||
explicitRegion ? { region: explicitRegion } : {},
|
||||
),
|
||||
).resolves.toBe(expectedRegion);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Bedrock stop reasons", () => {
|
||||
it.each([
|
||||
BedrockStopReason.CONTENT_FILTERED,
|
||||
BedrockStopReason.GUARDRAIL_INTERVENED,
|
||||
BedrockStopReason.MALFORMED_MODEL_OUTPUT,
|
||||
BedrockStopReason.MALFORMED_TOOL_USE,
|
||||
])("reports the provider stop reason %s", async (stopReason) => {
|
||||
vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
stream: streamEvents([
|
||||
{ messageStart: { role: ConversationRole.ASSISTANT } },
|
||||
{ messageStop: { stopReason } },
|
||||
]),
|
||||
} as never);
|
||||
|
||||
const result = await streamBedrock(bedrockModel({}), {
|
||||
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
|
||||
} as never).result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe(stopReason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bedrock thinking effort mapping", () => {
|
||||
|
||||
@@ -166,11 +166,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
profile: options.profile,
|
||||
};
|
||||
const configuredRegion = getConfiguredBedrockRegion(options);
|
||||
const requestRegion = options.region || getBedrockModelArnRegion(model.id) || configuredRegion;
|
||||
const hasConfiguredProfile = hasConfiguredBedrockProfile(options);
|
||||
const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl);
|
||||
const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint(
|
||||
model.baseUrl,
|
||||
configuredRegion,
|
||||
requestRegion,
|
||||
hasConfiguredProfile,
|
||||
);
|
||||
|
||||
@@ -187,11 +188,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
|
||||
// in Node.js/Bun environment only
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
// Region resolution: explicit option > env vars > SDK default chain.
|
||||
// When AWS_PROFILE is set, we leave region undefined so the SDK can
|
||||
// resovle it from aws profile configs. Otherwise fall back to us-east-1.
|
||||
if (configuredRegion) {
|
||||
config.region = configuredRegion;
|
||||
// Region resolution: explicit option > model ARN > env vars > SDK default chain.
|
||||
// When AWS_PROFILE is set, leave region undefined so the SDK can resolve it.
|
||||
if (requestRegion) {
|
||||
config.region = requestRegion;
|
||||
} else if (endpointRegion && useExplicitEndpoint) {
|
||||
config.region = endpointRegion;
|
||||
} else if (!hasConfiguredProfile) {
|
||||
@@ -220,7 +220,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
// Non-Node environment (browser): fall back to us-east-1 since
|
||||
// there's no config file resolution available.
|
||||
config.region =
|
||||
configuredRegion ||
|
||||
requestRegion ||
|
||||
(endpointRegion && useExplicitEndpoint ? endpointRegion : undefined) ||
|
||||
"us-east-1";
|
||||
}
|
||||
@@ -303,7 +303,11 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
model.provider,
|
||||
);
|
||||
} else {
|
||||
output.stopReason = mapStopReason(item.messageStop.stopReason);
|
||||
const mappedStop = mapStopReason(item.messageStop.stopReason);
|
||||
output.stopReason = mappedStop.stopReason;
|
||||
if (mappedStop.errorMessage) {
|
||||
output.errorMessage = mappedStop.errorMessage;
|
||||
}
|
||||
}
|
||||
} else if (item.metadata) {
|
||||
handleMetadata(item.metadata, model, output);
|
||||
@@ -1002,21 +1006,33 @@ function convertToolConfig(
|
||||
return { tools: bedrockTools, toolChoice: bedrockToolChoice };
|
||||
}
|
||||
|
||||
function mapStopReason(reason: string | undefined): StopReason {
|
||||
function mapStopReason(reason: string | undefined): {
|
||||
stopReason: StopReason;
|
||||
errorMessage?: string;
|
||||
} {
|
||||
switch (reason) {
|
||||
case BedrockStopReason.END_TURN:
|
||||
case BedrockStopReason.STOP_SEQUENCE:
|
||||
return "stop";
|
||||
return { stopReason: "stop" };
|
||||
case BedrockStopReason.MAX_TOKENS:
|
||||
case BedrockStopReason.MODEL_CONTEXT_WINDOW_EXCEEDED:
|
||||
return "length";
|
||||
return { stopReason: "length" };
|
||||
case BedrockStopReason.TOOL_USE:
|
||||
return "toolUse";
|
||||
return { stopReason: "toolUse" };
|
||||
case BedrockStopReason.CONTENT_FILTERED:
|
||||
case BedrockStopReason.GUARDRAIL_INTERVENED:
|
||||
case BedrockStopReason.MALFORMED_MODEL_OUTPUT:
|
||||
case BedrockStopReason.MALFORMED_TOOL_USE:
|
||||
return { stopReason: "error", errorMessage: reason };
|
||||
default:
|
||||
return "error";
|
||||
return reason ? { stopReason: "error", errorMessage: reason } : { stopReason: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
function getBedrockModelArnRegion(modelId: string): string | undefined {
|
||||
return /^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/.exec(modelId)?.[1];
|
||||
}
|
||||
|
||||
function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined {
|
||||
if (typeof process === "undefined") {
|
||||
return options.region;
|
||||
|
||||
@@ -40,8 +40,20 @@ function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) {
|
||||
afterEach(() => {
|
||||
mocks.fetchWithSsrFGuard.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function runDeviceFlowAfterFirstPoll(
|
||||
io: Parameters<typeof runGitHubCopilotDeviceFlow>[0],
|
||||
domain?: string,
|
||||
) {
|
||||
vi.useFakeTimers();
|
||||
const result = runGitHubCopilotDeviceFlow(io, domain);
|
||||
return Promise.all([result, vi.advanceTimersByTimeAsync(5_000)]).then(
|
||||
([flowResult]) => flowResult,
|
||||
);
|
||||
}
|
||||
|
||||
describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
it("bounds requests and returns authorized status and access token on successful flow", async () => {
|
||||
let callIdx = 0;
|
||||
@@ -64,7 +76,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
});
|
||||
|
||||
const showCode = vi.fn(async () => {});
|
||||
const result = await runGitHubCopilotDeviceFlow({ showCode, signal: controller.signal });
|
||||
const result = await runDeviceFlowAfterFirstPoll({ showCode, signal: controller.signal });
|
||||
|
||||
expect(result).toEqual({ status: "authorized", accessToken: "ghu_tok_xyz" });
|
||||
expect(showCode).toHaveBeenCalledWith({
|
||||
@@ -86,7 +98,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
return guardResponse({ error: "access_denied" }, 200, ACCESS_TOKEN_URL);
|
||||
});
|
||||
|
||||
const result = await runGitHubCopilotDeviceFlow({
|
||||
const result = await runDeviceFlowAfterFirstPoll({
|
||||
showCode: vi.fn(async () => {}),
|
||||
});
|
||||
expect(result).toEqual({ status: "access_denied" });
|
||||
@@ -102,7 +114,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
return guardResponse({ error: "expired_token" }, 200, ACCESS_TOKEN_URL);
|
||||
});
|
||||
|
||||
const result = await runGitHubCopilotDeviceFlow({
|
||||
const result = await runDeviceFlowAfterFirstPoll({
|
||||
showCode: vi.fn(async () => {}),
|
||||
});
|
||||
expect(result).toEqual({ status: "expired" });
|
||||
@@ -128,10 +140,25 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
|
||||
return guardResponse({}, 500, ACCESS_TOKEN_URL);
|
||||
});
|
||||
|
||||
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
|
||||
await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
|
||||
"GitHub device token failed: HTTP 500",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed access token response", async () => {
|
||||
let callIdx = 0;
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
}
|
||||
return guardResponse({ access_token: null, token_type: "bearer" }, 200, ACCESS_TOKEN_URL);
|
||||
});
|
||||
|
||||
await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
|
||||
"GitHub device flow returned an invalid access token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postGitHubDeviceFlowForm — response size bound", () => {
|
||||
@@ -208,7 +235,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
|
||||
};
|
||||
});
|
||||
|
||||
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
|
||||
await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow(
|
||||
"github-copilot.device-flow",
|
||||
);
|
||||
|
||||
@@ -246,7 +273,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
});
|
||||
|
||||
const showCode = vi.fn(async () => {});
|
||||
const result = await runGitHubCopilotDeviceFlow({ showCode }, GHE_DOMAIN);
|
||||
const result = await runDeviceFlowAfterFirstPoll({ showCode }, GHE_DOMAIN);
|
||||
|
||||
expect(result).toEqual({ status: "authorized", accessToken: "ghu_ghe_tok" });
|
||||
expect(urls).toEqual([gheDeviceCodeUrl, gheAccessTokenUrl]);
|
||||
@@ -271,3 +298,82 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
).rejects.toThrow("unexpected verification URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runGitHubCopilotDeviceFlow — polling intervals", () => {
|
||||
it("waits before the first poll and keeps cumulative slow_down increases", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-16T00:00:00Z"));
|
||||
const startedAt = Date.now();
|
||||
const pollTimes: number[] = [];
|
||||
const pollResponses = [
|
||||
{ error: "authorization_pending" },
|
||||
{ error: "slow_down" },
|
||||
{ error: "slow_down" },
|
||||
{ access_token: "test-access-token", token_type: "bearer" },
|
||||
];
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
if (params.url === DEVICE_CODE_URL) {
|
||||
const { interval: _interval, ...withoutInterval } = VALID_DEVICE_CODE_BODY;
|
||||
return guardResponse(withoutInterval);
|
||||
}
|
||||
pollTimes.push(Date.now());
|
||||
return guardResponse(pollResponses.shift(), 200, ACCESS_TOKEN_URL);
|
||||
});
|
||||
|
||||
const result = runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(pollTimes).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pollTimes).toEqual([startedAt + 5_000]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(pollTimes).toEqual([startedAt + 5_000, startedAt + 10_000]);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(pollTimes).toEqual([startedAt + 5_000, startedAt + 10_000, startedAt + 20_000]);
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
status: "authorized",
|
||||
accessToken: "test-access-token",
|
||||
});
|
||||
expect(pollTimes).toEqual([
|
||||
startedAt + 5_000,
|
||||
startedAt + 10_000,
|
||||
startedAt + 20_000,
|
||||
startedAt + 35_000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the interval returned with slow_down", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-16T00:00:00Z"));
|
||||
const startedAt = Date.now();
|
||||
const pollTimes: number[] = [];
|
||||
const pollResponses = [
|
||||
{ error: "slow_down", interval: 7 },
|
||||
{ access_token: "test-access-token", token_type: "bearer" },
|
||||
];
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
if (params.url === DEVICE_CODE_URL) {
|
||||
return guardResponse({ ...VALID_DEVICE_CODE_BODY, interval: 2 });
|
||||
}
|
||||
pollTimes.push(Date.now());
|
||||
return guardResponse(pollResponses.shift(), 200, ACCESS_TOKEN_URL);
|
||||
});
|
||||
|
||||
const result = runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(pollTimes).toEqual([startedAt + 2_000]);
|
||||
await vi.advanceTimersByTimeAsync(6_999);
|
||||
expect(pollTimes).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
status: "authorized",
|
||||
accessToken: "test-access-token",
|
||||
});
|
||||
expect(pollTimes).toEqual([startedAt + 2_000, startedAt + 9_000]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const GITHUB_DEVICE_FLOW_DEFAULT_INTERVAL_MS = 5_000;
|
||||
const GITHUB_DEVICE_FLOW_SLOW_DOWN_INCREMENT_MS = 5_000;
|
||||
// Data-residency GitHub Enterprise support: the device flow, token exchange, and
|
||||
// completions endpoints all live under the tenant host (e.g. "acme.ghe.com")
|
||||
// instead of github.com. The host is threaded in from the selected auth flow so
|
||||
@@ -55,6 +57,7 @@ type DeviceTokenResponse =
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
interval?: unknown;
|
||||
};
|
||||
|
||||
const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied");
|
||||
@@ -100,7 +103,10 @@ function parseDeviceCodeResponse(
|
||||
issuedAt: number,
|
||||
): DeviceCodeResponse {
|
||||
const expiresInMs = positiveSecondsToSafeMilliseconds(value.expires_in);
|
||||
const intervalMs = nonNegativeSecondsToSafeMilliseconds(value.interval);
|
||||
const intervalMs =
|
||||
value.interval === undefined
|
||||
? GITHUB_DEVICE_FLOW_DEFAULT_INTERVAL_MS
|
||||
: nonNegativeSecondsToSafeMilliseconds(value.interval);
|
||||
const expiresAt =
|
||||
expiresInMs === undefined
|
||||
? undefined
|
||||
@@ -199,7 +205,13 @@ async function pollForAccessToken(params: {
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
|
||||
let intervalMs = params.intervalMs;
|
||||
while (Date.now() < params.expiresAt) {
|
||||
await sleepGitHubDevicePollDelay(intervalMs, params.expiresAt, params.signal);
|
||||
if (Date.now() >= params.expiresAt) {
|
||||
break;
|
||||
}
|
||||
|
||||
const json = (await postGitHubDeviceFlowForm({
|
||||
url: accessTokenUrl(params.domain),
|
||||
body: bodyBase,
|
||||
@@ -207,17 +219,21 @@ async function pollForAccessToken(params: {
|
||||
domain: params.domain,
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
})) as DeviceTokenResponse;
|
||||
if ("access_token" in json && typeof json.access_token === "string") {
|
||||
return json.access_token;
|
||||
if ("access_token" in json) {
|
||||
if (typeof json.access_token === "string") {
|
||||
return json.access_token;
|
||||
}
|
||||
throw new Error("GitHub device flow returned an invalid access token");
|
||||
}
|
||||
|
||||
const err = "error" in json ? json.error : "unknown";
|
||||
const err = json.error;
|
||||
if (err === "authorization_pending") {
|
||||
await sleepGitHubDevicePollDelay(params.intervalMs, params.expiresAt, params.signal);
|
||||
continue;
|
||||
}
|
||||
if (err === "slow_down") {
|
||||
await sleepGitHubDevicePollDelay(params.intervalMs + 2000, params.expiresAt, params.signal);
|
||||
intervalMs =
|
||||
positiveSecondsToSafeMilliseconds(json.interval) ??
|
||||
Math.min(Number.MAX_SAFE_INTEGER, intervalMs + GITHUB_DEVICE_FLOW_SLOW_DOWN_INCREMENT_MS);
|
||||
continue;
|
||||
}
|
||||
if (err === "expired_token") {
|
||||
|
||||
Generated
+4
-4
@@ -14,7 +14,7 @@
|
||||
"@anthropic-ai/sdk": "0.109.1",
|
||||
"@clack/core": "1.4.2",
|
||||
"@clack/prompts": "1.6.0",
|
||||
"@earendil-works/pi-tui": "0.80.3",
|
||||
"@earendil-works/pi-tui": "0.80.9",
|
||||
"@google/genai": "2.10.0",
|
||||
"@grammyjs/runner": "2.0.3",
|
||||
"@grammyjs/transformer-throttler": "1.2.1",
|
||||
@@ -161,9 +161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.80.3",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz",
|
||||
"integrity": "sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==",
|
||||
"version": "0.80.9",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.9.tgz",
|
||||
"integrity": "sha512-unPTW8hRgIHEGjV8mJJ2jqm+fzgnRubes6V2FPk9ay1W9ZLofcpYQ3NDfrODXSci+oKbBpX9JyYUMfQV6jCA/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
||||
+1
-1
@@ -2025,7 +2025,7 @@
|
||||
"@anthropic-ai/sdk": "0.109.1",
|
||||
"@clack/core": "1.4.2",
|
||||
"@clack/prompts": "1.6.0",
|
||||
"@earendil-works/pi-tui": "0.80.3",
|
||||
"@earendil-works/pi-tui": "0.80.9",
|
||||
"@google/genai": "2.10.0",
|
||||
"@grammyjs/runner": "2.0.3",
|
||||
"@grammyjs/transformer-throttler": "1.2.1",
|
||||
|
||||
Generated
+5
-5
@@ -56,8 +56,8 @@ importers:
|
||||
specifier: 1.6.0
|
||||
version: 1.6.0
|
||||
'@earendil-works/pi-tui':
|
||||
specifier: 0.80.3
|
||||
version: 0.80.3
|
||||
specifier: 0.80.9
|
||||
version: 0.80.9
|
||||
'@google/genai':
|
||||
specifier: 2.10.0
|
||||
version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
|
||||
@@ -2871,8 +2871,8 @@ packages:
|
||||
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@earendil-works/pi-tui@0.80.3':
|
||||
resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==}
|
||||
'@earendil-works/pi-tui@0.80.9':
|
||||
resolution: {integrity: sha512-unPTW8hRgIHEGjV8mJJ2jqm+fzgnRubes6V2FPk9ay1W9ZLofcpYQ3NDfrODXSci+oKbBpX9JyYUMfQV6jCA/A==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
'@emnapi/core@1.11.1':
|
||||
@@ -9311,7 +9311,7 @@ snapshots:
|
||||
- opusscript
|
||||
- utf-8-validate
|
||||
|
||||
'@earendil-works/pi-tui@0.80.3':
|
||||
'@earendil-works/pi-tui@0.80.9':
|
||||
dependencies:
|
||||
get-east-asian-width: 1.6.0
|
||||
marked: 18.0.5
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Hyperlink markdown helpers render markdown links with TUI hyperlink styling.
|
||||
import type { Component, DefaultTextStyle, MarkdownTheme } from "@earendil-works/pi-tui";
|
||||
import type {
|
||||
Component,
|
||||
DefaultTextStyle,
|
||||
MarkdownOptions,
|
||||
MarkdownTheme,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { Markdown } from "@earendil-works/pi-tui";
|
||||
import { addOsc8Hyperlinks, extractUrls } from "../osc8-hyperlinks.js";
|
||||
|
||||
@@ -17,9 +22,10 @@ export class HyperlinkMarkdown implements Component {
|
||||
paddingX: number,
|
||||
paddingY: number,
|
||||
theme: MarkdownTheme,
|
||||
options?: DefaultTextStyle,
|
||||
defaultTextStyle?: DefaultTextStyle,
|
||||
options?: MarkdownOptions,
|
||||
) {
|
||||
this.inner = new Markdown(text, paddingX, paddingY, theme, options);
|
||||
this.inner = new Markdown(text, paddingX, paddingY, theme, defaultTextStyle, options);
|
||||
this.urls = extractUrls(text);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,21 @@ import { markdownTheme } from "../theme/theme.js";
|
||||
import { HyperlinkMarkdown } from "./hyperlink-markdown.js";
|
||||
|
||||
// Shared markdown message wrapper with a leading spacer for chat-log rows.
|
||||
type MarkdownOptions = ConstructorParameters<typeof HyperlinkMarkdown>[4];
|
||||
type DefaultTextStyle = ConstructorParameters<typeof HyperlinkMarkdown>[4];
|
||||
type MarkdownOptions = ConstructorParameters<typeof HyperlinkMarkdown>[5];
|
||||
|
||||
/** Container-backed markdown message that can update text in place. */
|
||||
export class MarkdownMessageComponent extends Container {
|
||||
private body: HyperlinkMarkdown;
|
||||
|
||||
constructor(text: string, y: number, options?: MarkdownOptions) {
|
||||
constructor(
|
||||
text: string,
|
||||
y: number,
|
||||
defaultTextStyle?: DefaultTextStyle,
|
||||
options?: MarkdownOptions,
|
||||
) {
|
||||
super();
|
||||
this.body = new HyperlinkMarkdown(text, 0, y, markdownTheme, options);
|
||||
this.body = new HyperlinkMarkdown(text, 0, y, markdownTheme, defaultTextStyle, options);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeTestText } from "../../../test/helpers/normalize-text.js";
|
||||
import { UserMessageComponent } from "./user-message.js";
|
||||
|
||||
describe("UserMessageComponent", () => {
|
||||
it("preserves ordered-list markers and backslash escapes", () => {
|
||||
const message = new UserMessageComponent(String.raw`7. first
|
||||
9. second
|
||||
|
||||
Escaped \*literal\*`);
|
||||
|
||||
const rendered = message.render(80).map(normalizeTestText).join("\n");
|
||||
|
||||
expect(rendered).toContain("7. first");
|
||||
expect(rendered).toContain("9. second");
|
||||
expect(rendered).toContain(String.raw`\*literal\*`);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,17 @@ import { MarkdownMessageComponent } from "./markdown-message.js";
|
||||
/** Markdown chat-log row styled as user input. */
|
||||
export class UserMessageComponent extends MarkdownMessageComponent {
|
||||
constructor(text: string) {
|
||||
super(text, 1, {
|
||||
bgColor: (line) => theme.userBg(line),
|
||||
color: (line) => theme.userText(line),
|
||||
});
|
||||
super(
|
||||
text,
|
||||
1,
|
||||
{
|
||||
bgColor: (line) => theme.userBg(line),
|
||||
color: (line) => theme.userText(line),
|
||||
},
|
||||
{
|
||||
preserveOrderedListMarkers: true,
|
||||
preserveBackslashEscapes: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user