mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Merge current Runner compatibility and complete profile target checks
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: fix(connections): authorize all agents affected by project profile bindings perf(runner): keep event journal writes off the HTTP thread (#17745) Bound native OpenCode assigned-tool catalog projection (#17740) fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
This commit is contained in:
commit
7687c2dfab
58 files changed
+1254
-556
No files matched your search
@@ -60,8 +60,12 @@ title sizes remain larger.
|
||||
|
||||
The bottom navigation responds to accumulated scrolling, ignoring small
|
||||
reversals and Safari's edge bounce. It glides out and eases back in with shared
|
||||
motion tokens; the task composer follows the same motion. Keep page padding
|
||||
stable while the navigation moves, and honor reduced-motion preferences.
|
||||
motion tokens. Keep page padding stable while the navigation moves, and honor
|
||||
reduced-motion preferences.
|
||||
|
||||
Mobile task conversations size their shell to the visible viewport, with one
|
||||
message scroller and a normal flex footer for the composer. Navigation stays at
|
||||
the layout viewport bottom, behind the keyboard.
|
||||
|
||||
Cold task opens reveal the instruction and recent conversation independently of
|
||||
plans, related metadata, and historical run activity. Show the latest human
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current source declaration (2026-10-26): **Copilot profile v60** binds the shared
|
||||
Current source declaration (2026-10-26): **Copilot profile v61** binds controller
|
||||
journal retirement and the historical profile decoder. Its digest is
|
||||
`sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3`.
|
||||
The v60 declaration remains unchanged and readable as history. This source
|
||||
identity update changes no Copilot executable, arguments, permission policy,
|
||||
tool receipts, or qualification flags. Offline compatibility checks supply no
|
||||
new live qualification; prior live outcomes retain their original identities.
|
||||
|
||||
Historical source declaration (2026-10-26): **Copilot profile v60** binds the shared
|
||||
sources that carry Codex's explicit hosted-search restriction. Its digest is
|
||||
`sha256:ca3c2b38ed5f6e25cf1c96c1f73781a86cc90c1c0add93f64716c66f28b3f3aa`.
|
||||
The v59 declaration remains unchanged and readable as history. Exact-current
|
||||
|
||||
@@ -2015,6 +2015,25 @@ not grant execution authority: calls still use the existing gateway policy,
|
||||
approval, revocation, and audit paths.
|
||||
|
||||
|
||||
Native OpenCode sessions use the server-owned assigned-tool relay for local and
|
||||
remote execution. The provider receives only bounded search and call schemas,
|
||||
even when its protocol can fit the full catalog. This bounds provider grammar
|
||||
compilation independently of the transport's tool-count and byte limits. Search
|
||||
returns pinned tool aliases and schemas, intersects fresh grants, and respects
|
||||
planning/ask restrictions. OpenCode discovery pages use a 32 KiB budget; larger
|
||||
schemas use 16K-character chunks so JSON escaping and the result envelope fit
|
||||
its HTTP MCP bridge. Other harnesses keep the existing PRP discovery budgets.
|
||||
Calls retain the original gateway binding and its
|
||||
argument validation, approvals, revocation, and audit rules; prompt arguments
|
||||
cannot choose a gateway or credential. Assigned gateway launch credentials stay
|
||||
on the controller. The MCP bridge preserves exact declared operation names,
|
||||
including names that start with `paperclip_`; only a catalog-bound transport
|
||||
prefix may resolve to that declared name. Shortened or unknown names do not
|
||||
gain authority. OpenCode sessions with the former direct catalog rotate their
|
||||
provider checkpoint before reuse; other providers retain their existing tool
|
||||
contract fingerprints.
|
||||
|
||||
|
||||
### Use real connection requests (2026-09-14)
|
||||
|
||||
When a user asks to connect a known service, the agent searches for that service
|
||||
|
||||
@@ -162,6 +162,18 @@ No grant, token, authority cache or provider dispatch bypass is added. The
|
||||
[executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
|
||||
remain separate from live latency and permitted/denied agent acceptance.
|
||||
|
||||
Scoped OpenCode assigned-tool source evidence: local and remote native sessions
|
||||
use the server-owned authenticated relay. The provider sees bounded
|
||||
`paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` schemas,
|
||||
then uses pinned aliases. Search intersects fresh grants and work modes; calls
|
||||
retain the run's original gateway policy, validation, approvals, revocation and
|
||||
audit path. Prompt arguments cannot select gateway authority. Assigned gateway
|
||||
launch credentials stay on the controller. Former direct-catalog OpenCode
|
||||
checkpoints refresh; other provider fingerprints stay unchanged. The
|
||||
[projection controls](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
|
||||
and [wiring controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
|
||||
are source evidence, not live permitted/denied sandbox acceptance.
|
||||
|
||||
For a linked Codex subscription with server-resolved managed credential provenance,
|
||||
setup checks the resolved owner and stages a private probe copy before local or
|
||||
remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is
|
||||
|
||||
@@ -146,6 +146,16 @@ is released before failure returns. Repeat a warm follow-up and retain its actua
|
||||
reply time. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts)
|
||||
cover ordering, failure drain and credential projection; they do not qualify a
|
||||
live response-time target.
|
||||
For native OpenCode with assigned connections, inspect the provider tool surface
|
||||
on both local and sandbox execution. It should contain bounded assigned-tool
|
||||
search/call schemas. Discover an allowed tool, call its returned alias, revoke
|
||||
its grant, and confirm discovery and execution enforce that revocation. Repeat
|
||||
in planning/ask mode with a write tool and confirm denial. The controller must
|
||||
retain the assigned gateway URL and token. An older direct-catalog OpenCode
|
||||
checkpoint must refresh without changing another provider's fingerprint. The
|
||||
[assigned-tool](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts)
|
||||
and executor controls cover source behavior; live sandbox discovery and a real
|
||||
call remain separate acceptance evidence.
|
||||
Missing dependencies, failed authentication, invalid models and
|
||||
incompatible custom settings must remain actionable errors without silent runner
|
||||
switching. Saved unchanged execution can still be tested without applying new
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 61,
|
||||
"agentProfileVersion": 62,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9",
|
||||
"commandDigest": "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"claude": {
|
||||
|
||||
@@ -175,6 +175,22 @@ The ACK means that every source event through sequence 9 is committed or
|
||||
deduplicated. The runner rejects an ACK that moves backward or beyond its
|
||||
produced source cursor. It removes only events at or below a valid ACK.
|
||||
|
||||
The controller stages `item.delta` output snapshots with asynchronous file writes and file
|
||||
flushes. A slow volume does not block unrelated HTTP requests during those
|
||||
operations. The controller still publishes the snapshot atomically, flushes its
|
||||
directory, and waits for persistence before acknowledging an event.
|
||||
A newer command or authenticated connection can save the
|
||||
same event while staging is pending; the older snapshot cannot replace that
|
||||
newer durable state. A failed write fences further authority until reload.
|
||||
Lifecycle, control, authentication, and semantic tool events retain synchronous
|
||||
persistence before transport readers can observe their state. Semantic tool
|
||||
admission and dispatch cannot yield to a replacement connection between those operations.
|
||||
Controller replacement must still stop ingress and drain admitted processing
|
||||
before a successor reuses the journal. External control routes stop their
|
||||
reconnect loop before the controller joins admitted work. An incomplete drain
|
||||
cannot certify a reusable checkpoint. Snapshots use compact JSON; existing
|
||||
indented snapshots remain readable.
|
||||
|
||||
For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and
|
||||
reports the prior cursor once after reconnect. The runner sends the same bytes
|
||||
again. The mock increments delivery count, keeps one logical event, and sends
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9",
|
||||
"sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
|
||||
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const fixture = resolve(root, "test/fixtures/copilot-profile-v61-identity.json");
|
||||
const fixture = resolve(root, "test/fixtures/copilot-profile-v62-identity.json");
|
||||
const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8"));
|
||||
const declaration = { ...historical.declaration, agentProfileVersion: 61 };
|
||||
const declaration = { ...historical.declaration, agentProfileVersion: 62 };
|
||||
delete declaration.permissionIdentitySourceSha256;
|
||||
const sources = {
|
||||
displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts",
|
||||
|
||||
@@ -658,6 +658,7 @@ describe("native backend factory", () => {
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v7-identity.json"],
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v10-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v14-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v60-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v13-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v14-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v18-identity.json"],
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "node:fs";
|
||||
import { connect, type Socket } from "node:net";
|
||||
import nodeFs from "node:fs";
|
||||
import fsPromises from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
@@ -47,6 +49,117 @@ const identity: DurableRecoveryIdentity = {
|
||||
const expectedRunnerVersion = "0.3.0";
|
||||
const expectedRunnerDigest = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
it.each(["event", "newer_command", "replacement", "write_failure", "drain", "semantic_replacement"] as const)(
|
||||
"keeps HTTP responsive during a durable event write and preserves %s", async (mode) => {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "runner-event-io-"));
|
||||
const dispatch = vi.fn(async () => {
|
||||
expect(JSON.parse(readFileSync(resolve(root, "control-plane-state.json"), "utf8")).ackedSourceSeq).toBe(1);
|
||||
return { result: { ok: true } };
|
||||
});
|
||||
const core = new DurablePrpControlPlane({ stateDirectory: root, identity,
|
||||
expectedRunnerVersion, expectedRunnerDigest, onCommittedEvent: async () => {}, onSemanticToolInput: dispatch });
|
||||
const http = createServer((_req, res) => res.end("responsive"));
|
||||
let release!: () => void;
|
||||
const barrier = new Promise<void>(resolveWrite => { release = resolveWrite; });
|
||||
const originalOpen = fsPromises.open;
|
||||
let staged = false;
|
||||
let holdFirst = true;
|
||||
let client: AuthenticatedClient | null = null;
|
||||
let replacement: AuthenticatedClient | null = null;
|
||||
const open = vi.spyOn(fsPromises, "open").mockImplementation(async (...args) => {
|
||||
const file = await originalOpen(...args);
|
||||
if (holdFirst && String(args[0]).includes(".control-plane-state.json.")) {
|
||||
holdFirst = false;
|
||||
const sync = file.sync.bind(file);
|
||||
file.sync = async () => {
|
||||
staged = true;
|
||||
await barrier;
|
||||
if (mode === "write_failure") throw new Error("controlled disk failure");
|
||||
await sync();
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
await new Promise<void>(resolveListen => http.listen(0, "127.0.0.1", resolveListen));
|
||||
client = (await authenticate(core, core.issueBootstrapTicket()))!;
|
||||
const event = semanticInputEvent();
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
if (mode !== "semantic_replacement") {
|
||||
payload.eventType = "item.delta";
|
||||
payload.priority = 2;
|
||||
payload.payload = { delta: "output while the volume is slow" };
|
||||
}
|
||||
expect(validatePrpEvent(payload)).toMatchObject({ ok: true });
|
||||
sendSecure(client, event);
|
||||
await vi.waitFor(() => expect(staged || dispatch.mock.calls.length > 0).toBe(true));
|
||||
if (mode === "semantic_replacement") {
|
||||
replacement = (await authenticate(core, client.leaseToken!))!;
|
||||
release();
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(core.store.state.commands.some(command => command.type === "semantic_tool.result")).toBe(true));
|
||||
return;
|
||||
}
|
||||
// The actual socket request completes while the real file's fsync is held.
|
||||
const address = http.address() as { port: number };
|
||||
expect(await (await fetch(`http://127.0.0.1:${address.port}`)).text()).toBe("responsive");
|
||||
expect(JSON.parse(readFileSync(core.store.path, "utf8")).ackedSourceSeq).toBe(0);
|
||||
if (mode === "newer_command") core.queueCommand("session.snapshot", {}, "command_during_event");
|
||||
let drained = false;
|
||||
const retirement = mode === "drain" ? (async () => {
|
||||
await core.stop();
|
||||
await core.drainPendingConnectionProcessing();
|
||||
drained = true;
|
||||
})() : null;
|
||||
if (retirement) {
|
||||
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
|
||||
expect(drained).toBe(false);
|
||||
}
|
||||
if (mode === "replacement") {
|
||||
replacement = (await authenticate(core, client.leaseToken!))!;
|
||||
const second = structuredClone(event);
|
||||
const next = second.payload as Record<string, unknown>;
|
||||
next.sourceSeq = 2;
|
||||
next.sourceEventId = "output-event-2";
|
||||
sendSecure(replacement, second);
|
||||
expect(await receiveSecure(replacement)).toMatchObject({ kind: "ack", payload: { ackedSourceSeq: 2 } });
|
||||
}
|
||||
release();
|
||||
if (retirement) {
|
||||
expect(await receiveSecure(client)).toBeNull();
|
||||
await retirement;
|
||||
expect(drained).toBe(true);
|
||||
} else if (mode === "write_failure") {
|
||||
expect(await receiveSecure(client)).toBeNull();
|
||||
expect(() => core.queueCommand("session.snapshot")).toThrow("indeterminate");
|
||||
} else if (mode !== "replacement") {
|
||||
expect(await receiveSecure(client)).toMatchObject({ kind: "ack", payload: { ackedSourceSeq: 1 } });
|
||||
}
|
||||
await core.stop();
|
||||
await core.drainPendingConnectionProcessing();
|
||||
const restored = new DurablePrpControlPlane({ stateDirectory: root, identity,
|
||||
expectedRunnerVersion, expectedRunnerDigest });
|
||||
const expectedEvents = mode === "replacement" ? 2 : mode === "write_failure" ? 0 : 1;
|
||||
expect(restored.store.state.ackedSourceSeq).toBe(expectedEvents);
|
||||
expect(restored.store.state.committedEvents).toHaveLength(expectedEvents);
|
||||
if (mode === "newer_command") {
|
||||
expect(restored.store.state.commands.some(command => command.commandId === "command_during_event")).toBe(true);
|
||||
}
|
||||
expect(nodeFs.readdirSync(root).filter(name => name.endsWith(".tmp"))).toEqual([]);
|
||||
} finally {
|
||||
release();
|
||||
open.mockRestore();
|
||||
client?.socket.destroy();
|
||||
replacement?.socket.destroy();
|
||||
await core.stop();
|
||||
await core.drainPendingConnectionProcessing();
|
||||
await new Promise<void>(resolveClose => http.close(() => resolveClose()));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("commits credential updates before ACK and preserves replay identity", async () => {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "runner-credential-update-"));
|
||||
let release!: () => void;
|
||||
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
type Stats,
|
||||
} from "node:fs";
|
||||
import { createServer, type IncomingMessage, type Server } from "node:http";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { withStagedPrivateWrite } from "./staged-private-write.js";
|
||||
import { verifyPrivateRegularFile } from "./private-state-file.js";
|
||||
|
||||
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js";
|
||||
@@ -1122,22 +1123,6 @@ function verifyPrivateDirectory(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPrivateRegularFile(file: Stats, path: string): void {
|
||||
if (!file.isFile()) {
|
||||
throw new Error(`Private state path is not a regular file: ${path}`);
|
||||
}
|
||||
if (process.platform !== "win32") {
|
||||
if ((file.mode & 0o777) !== 0o600) {
|
||||
throw new Error(`Private state file does not use mode 0600: ${path}`);
|
||||
}
|
||||
if (process.geteuid !== undefined && file.uid !== process.geteuid()) {
|
||||
throw new Error(
|
||||
`Private state file is not owned by the daemon user: ${path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readPrivateFile(path: string): string | null {
|
||||
let descriptor: number;
|
||||
try {
|
||||
@@ -1218,6 +1203,8 @@ class DurableCoreStore {
|
||||
readonly path: string;
|
||||
#state: StoredCoreState;
|
||||
#writeIndeterminate = false;
|
||||
#revision = 0;
|
||||
#savedRevision = 0;
|
||||
|
||||
constructor(directory: string, identity: DurableRecoveryIdentity) {
|
||||
try {
|
||||
@@ -1255,7 +1242,33 @@ class DurableCoreStore {
|
||||
|
||||
save(): void {
|
||||
this.assertWritable();
|
||||
atomicPrivateWrite(this.path, `${JSON.stringify(this.#state, null, 2)}\n`);
|
||||
const revision = ++this.#revision;
|
||||
try {
|
||||
atomicPrivateWrite(this.path, `${JSON.stringify(this.#state)}\n`);
|
||||
this.#savedRevision = revision;
|
||||
} catch (error) {
|
||||
this.#writeIndeterminate = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveEvent(): Promise<void> {
|
||||
this.assertWritable();
|
||||
const revision = ++this.#revision;
|
||||
try {
|
||||
await withStagedPrivateWrite(this.path, `${JSON.stringify(this.#state)}\n`, (temporary) => {
|
||||
this.assertWritable();
|
||||
// A command or replacement connection can persist this event and newer
|
||||
// authority while its file is staged. Never roll that state back.
|
||||
if (this.#savedRevision >= revision) return;
|
||||
renameSync(temporary, this.path);
|
||||
syncParentDirectory(this.path);
|
||||
this.#savedRevision = revision;
|
||||
});
|
||||
} catch (error) {
|
||||
this.#writeIndeterminate = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
assertWritable(): void {
|
||||
@@ -1268,9 +1281,11 @@ class DurableCoreStore {
|
||||
/** Persist a complete candidate before publishing any new authority in memory. */
|
||||
commit(candidate: StoredCoreState): void {
|
||||
this.assertWritable();
|
||||
const revision = ++this.#revision;
|
||||
try {
|
||||
atomicPrivateWrite(this.path, `${JSON.stringify(candidate, null, 2)}\n`);
|
||||
atomicPrivateWrite(this.path, `${JSON.stringify(candidate)}\n`);
|
||||
this.#state = candidate;
|
||||
this.#savedRevision = revision;
|
||||
} catch (error) {
|
||||
// Rename may already have succeeded before directory fsync failed.
|
||||
// Never overwrite that possibly durable receipt using stale memory.
|
||||
@@ -3292,7 +3307,14 @@ export class DurablePrpControlPlane {
|
||||
});
|
||||
this.#store.state.ackedSourceSeq = sourceSeq;
|
||||
}
|
||||
this.#store.save();
|
||||
// Output deltas have no lifecycle or control effects. Other events must
|
||||
// become durable before transport readers can observe them in memory.
|
||||
if (eventType === "item.delta") {
|
||||
await this.#store.saveEvent();
|
||||
if (this.#eventOwner !== connection || this.#identity !== identity || this.#protocolIntegrityError !== null) return;
|
||||
} else {
|
||||
this.#store.save();
|
||||
}
|
||||
|
||||
if (
|
||||
isSemanticInput &&
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Stats } from "node:fs";
|
||||
|
||||
export function verifyPrivateRegularFile(file: Stats, path: string): void {
|
||||
if (!file.isFile()) {
|
||||
throw new Error(`Private state path is not a regular file: ${path}`);
|
||||
}
|
||||
if (process.platform !== "win32") {
|
||||
if ((file.mode & 0o777) !== 0o600) {
|
||||
throw new Error(`Private state file does not use mode 0600: ${path}`);
|
||||
}
|
||||
if (process.geteuid !== undefined && file.uid !== process.geteuid()) {
|
||||
throw new Error(`Private state file is not owned by the daemon user: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import { verifyPrivateRegularFile } from "./private-state-file.js";
|
||||
|
||||
/** Stage durable bytes off the event loop; the owner publishes them without yielding. */
|
||||
export async function withStagedPrivateWrite(
|
||||
path: string,
|
||||
contents: string,
|
||||
publish: (temporary: string) => void,
|
||||
): Promise<void> {
|
||||
const temporary = resolve(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
||||
const file = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT |
|
||||
constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
||||
try {
|
||||
try {
|
||||
if (process.platform !== "win32") await file.chmod(0o600);
|
||||
const metadata = await file.stat();
|
||||
verifyPrivateRegularFile(metadata, temporary);
|
||||
await file.writeFile(contents, "utf8");
|
||||
await file.sync();
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
publish(temporary);
|
||||
} finally {
|
||||
// Publication can either rename this file or retain a newer durable write.
|
||||
await fs.unlink(temporary).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -87,19 +87,34 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current profile with environment launch preparation", () => {
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(61);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(62);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined();
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9");
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52");
|
||||
});
|
||||
|
||||
it("preserves v60 while binding delegated connection guidance in v61", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v60-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8"));
|
||||
it("preserves v61 while binding delegated connection guidance in v62", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v62-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
|
||||
"agentProfileVersion", "nativeRuntimePromptsSourceSha256", "profileCompatibilitySourceSha256",
|
||||
]);
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 61)).toBe(true);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("preserves v60 while binding controller journal retirement in v61", async () => {
|
||||
const priorBytes = readFileSync(new URL("../../../test/fixtures/copilot-profile-v60-identity.json", import.meta.url));
|
||||
expect(createHash("sha256").update(priorBytes).digest("hex")).toBe("e3c45b0311517d05f06ed10f357db713ae157cfa87e4a8becf087c5b6dfa7003");
|
||||
const prior = JSON.parse(priorBytes.toString("utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
|
||||
"agentProfileVersion", "profileCompatibilitySourceSha256", "runtimeRequestBridgeSourceSha256",
|
||||
]);
|
||||
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 60)).toBe(true);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot,
|
||||
agentProfileVersion: 60, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned");
|
||||
});
|
||||
|
||||
it("preserves v59 while binding the Codex-only hosted search restriction in v60", async () => {
|
||||
|
||||
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 61,
|
||||
"agentProfileVersion": 62,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9",
|
||||
"commandDigest": "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"claude": {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js";
|
||||
|
||||
/** Decodable historical profile revisions, not permission to launch them.
|
||||
* Exact current profile/package/digest admission is checked separately. */
|
||||
type HistoricalAcpxProfileVersion = 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;
|
||||
type HistoricalAcpxProfileVersion = 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;
|
||||
type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"];
|
||||
export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion;
|
||||
|
||||
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
|
||||
pi: [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], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [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],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [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],
|
||||
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
};
|
||||
|
||||
|
||||
@@ -94,6 +94,33 @@ describe("OpenCode MCP bridge", () => {
|
||||
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
|
||||
});
|
||||
|
||||
it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => {
|
||||
const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"];
|
||||
const handler = vi.fn(async call => ({ operation: call.tool }));
|
||||
const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler });
|
||||
bridges.push(bridge);
|
||||
const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
|
||||
expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]);
|
||||
for (const [i, name] of names.entries()) {
|
||||
for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) {
|
||||
const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name;
|
||||
const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json();
|
||||
expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } });
|
||||
}
|
||||
}
|
||||
const before = handler.mock.calls.length;
|
||||
expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
||||
expect(handler).toHaveBeenCalledTimes(before);
|
||||
});
|
||||
|
||||
it("does not authorize a shortened operation when only its prefixed name is declared", async () => {
|
||||
const handler = vi.fn(async () => ({ ok: true }));
|
||||
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler });
|
||||
bridges.push(bridge);
|
||||
expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes all supported OpenCode MCP prefixes", () => {
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
|
||||
|
||||
@@ -108,7 +108,7 @@ function normalizeTools(
|
||||
const tools: OpenCodeMcpToolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of input) {
|
||||
const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : "";
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
||||
if (!name || seen.has(name)) continue;
|
||||
const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" };
|
||||
tools.push({
|
||||
@@ -218,7 +218,12 @@ async function handleRequest(
|
||||
}
|
||||
const params = isRecord(message.params) ? message.params : {};
|
||||
const rawName = typeof params.name === "string" ? params.name : "";
|
||||
const tool = canonicalOpenCodeMcpToolName(rawName);
|
||||
// A declared operation may itself start with paperclip_. Preserve that
|
||||
// authority identity before accepting an OpenCode transport prefix alias.
|
||||
const exactName = rawName.trim();
|
||||
const tool = context.admittedTools.some(entry => entry.name === exactName)
|
||||
? exactName
|
||||
: canonicalOpenCodeMcpToolName(exactName);
|
||||
if (!context.admittedTools.some((entry) => entry.name === tool)) {
|
||||
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
|
||||
return;
|
||||
|
||||
@@ -2219,6 +2219,27 @@ describe("OpenCodeServerDriver", () => {
|
||||
} finally { await session.close({ reason: "monitor default test complete" }); }
|
||||
});
|
||||
|
||||
it("keeps assigned discovery operation IDs at the authenticated MCP and controller boundary", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-assigned-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
const calls: string[] = [];
|
||||
const operation = "paperclip_search_assigned_tools";
|
||||
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4.1-flash-1017", runtimeDirectory: root, command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
dynamicTools: [{ name: operation, inputSchema: { type: "object" } }],
|
||||
dynamicToolHandler: async call => { calls.push(call.tool); if (call.tool !== operation) throw new Error("unauthorized operation"); return { tools: [] }; },
|
||||
});
|
||||
const session = await driver.openSession({ runId: "assigned-name", normalizedSessionId: "assigned-name", workingDirectory: workspace });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "Discover assigned tools." } });
|
||||
const events = await collectTurnEvents(session.events());
|
||||
expect(calls).toEqual([operation]);
|
||||
expect(events.some(event => event.eventType === "item.completed" && (event.payload as { item?: { is_error?: boolean } }).item?.is_error)).toBe(false);
|
||||
} finally { await session.close({ reason: "assigned operation identity test" }); }
|
||||
});
|
||||
|
||||
it("normalizes a structured block result", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
||||
|
||||
@@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
callId: string;
|
||||
arguments: unknown;
|
||||
}): Promise<unknown> {
|
||||
const tool = canonicalOpenCodeMcpToolName(call.tool);
|
||||
// The authenticated MCP bridge has resolved this against its closed
|
||||
// declared catalog. A second prefix strip changes legitimate operation IDs.
|
||||
const tool = call.tool;
|
||||
const turnId = this.#activeTurnId;
|
||||
if (turnId === null)
|
||||
throw new Error("OpenCode tool call is not bound to an active turn");
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal)
|
||||
}
|
||||
async start() {}
|
||||
async stop() {}
|
||||
async drainPendingConnectionProcessing() {}
|
||||
disconnectActiveRunner() {}
|
||||
persistRunAttachTemplate() {}
|
||||
issueBootstrapTicket() { return {}; }
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal)
|
||||
}
|
||||
async start() {}
|
||||
async stop() {}
|
||||
async drainPendingConnectionProcessing() {}
|
||||
disconnectActiveRunner() {}
|
||||
persistRunAttachTemplate() {}
|
||||
issueBootstrapTicket() { return {}; }
|
||||
|
||||
@@ -726,6 +726,7 @@ async function releaseRunnerProcessOwnership(input: {
|
||||
((settlement: "settled" | "unsettled") => Promise<void> | void) | null;
|
||||
forceKill: () => Promise<void> | void;
|
||||
release: (() => Promise<void> | void) | null;
|
||||
retireController?: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
let releaseFailure: unknown;
|
||||
try {
|
||||
@@ -735,6 +736,9 @@ async function releaseRunnerProcessOwnership(input: {
|
||||
}
|
||||
let checkpointFailure: unknown;
|
||||
try {
|
||||
// External ingress must stop reconnecting before admitted journal writes
|
||||
// can be joined. A failed retirement cannot authorize a new checkpoint.
|
||||
await input.retireController?.();
|
||||
if (input.checkpoint !== null) {
|
||||
// Release only the authenticated control route first. In provider-ingress
|
||||
// mode this stops its reconnect loop; it does not release the sandbox
|
||||
@@ -3569,6 +3573,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
readonly #closedSignal: Promise<void>;
|
||||
#resolveClosedSignal!: () => void;
|
||||
#closePromise: Promise<void> | null = null;
|
||||
#detachPromise: Promise<void> | null = null;
|
||||
#startupOperation: Promise<unknown> | null = null;
|
||||
#attachmentOperation: Promise<void> | null = null;
|
||||
#startupDrainTimedOut = false;
|
||||
@@ -4466,7 +4471,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
close(reason?: string): Promise<void> {
|
||||
// Detachment relinquishes process ownership. A late execution finalizer
|
||||
// must not suspend or signal the runner now owned by the next controller.
|
||||
if (this.#controllerDetachedForRestart) return Promise.resolve();
|
||||
if (this.#controllerDetachedForRestart)
|
||||
return this.#detachPromise ?? Promise.resolve();
|
||||
if (reason) {
|
||||
this.#diagnostic(
|
||||
`runner transport close requested: ${reason.replaceAll(/[\r\n]/g, " ").slice(0, 1_000)}`,
|
||||
@@ -4476,8 +4482,33 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async detachControllerForRestart(): Promise<void> {
|
||||
detachControllerForRestart(): Promise<void> {
|
||||
this.#detachPromise ??= this.#detachControllerOnce();
|
||||
return this.#detachPromise;
|
||||
}
|
||||
|
||||
async #stopAndDrainAuthority(deadline: number): Promise<void> {
|
||||
const core = this.#core;
|
||||
if (!core) return;
|
||||
await core.stop();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
core.drainPendingConnectionProcessing(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(
|
||||
"Controller journal drain timed out; preserve the session before replacing its writer.",
|
||||
)), Math.max(0, deadline - Date.now()));
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async #detachControllerOnce(): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
const deadline = Date.now() + runnerCloseGraceMs(this.options);
|
||||
this.#controllerDetachedForRestart = true;
|
||||
this.#closed = true;
|
||||
this.#resolveClosedSignal();
|
||||
@@ -4495,13 +4526,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
`controller route release failed during restart detach: ${String(error)}`,
|
||||
);
|
||||
});
|
||||
await this.#core?.stop().catch((error: unknown) => {
|
||||
this.#diagnostic(
|
||||
`controller authority stop failed during restart detach: ${String(error)}`,
|
||||
);
|
||||
});
|
||||
// A successor must not share this journal with an admitted async write.
|
||||
// A timeout rejects detachment, including repeated calls, instead of
|
||||
// reporting that the old writer has retired.
|
||||
try {
|
||||
await this.#stopAndDrainAuthority(deadline);
|
||||
} finally {
|
||||
this.#queue.close();
|
||||
}
|
||||
this.#handle = null;
|
||||
this.#queue.close();
|
||||
this.#diagnostic(
|
||||
"controller authority detached for restart; durable runner left alive",
|
||||
);
|
||||
@@ -4725,6 +4758,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
this.#evidence.runnerSignal = result.signal as NodeJS.Signals | null;
|
||||
},
|
||||
release: this.#controlPlaneRelease,
|
||||
retireController: () => this.#stopAndDrainAuthority(closeDeadline),
|
||||
});
|
||||
} finally {
|
||||
await this.#core?.stop();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js";
|
||||
import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, runnerdRecoveryInternals } from "./runnerd-codex-transport.js";
|
||||
|
||||
it.each(["resolved", "rejected"] as const)("keeps the checkpoint behind controller retirement (%s)", async outcome => {
|
||||
const steps: string[] = [];
|
||||
let release!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const barrier = new Promise<void>((resolveDrain, rejectDrain) => { release = resolveDrain; reject = rejectDrain; });
|
||||
const operation = runnerdRecoveryInternals.releaseRunnerProcessOwnership({
|
||||
runnerSettled: true,
|
||||
release: () => { steps.push("route"); },
|
||||
retireController: async () => { steps.push("drain"); await barrier; },
|
||||
checkpoint: () => { steps.push("checkpoint"); },
|
||||
forceKill: () => { steps.push("containment"); },
|
||||
});
|
||||
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
|
||||
expect(steps).toEqual(["route", "drain"]);
|
||||
if (outcome === "rejected") {
|
||||
const failed = expect(operation).rejects.toThrow("journal unavailable");
|
||||
reject(new Error("journal unavailable"));
|
||||
await failed;
|
||||
expect(steps).toEqual(["route", "drain", "containment"]);
|
||||
} else {
|
||||
release();
|
||||
await operation;
|
||||
expect(steps).toEqual(["route", "drain", "checkpoint", "containment"]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["drain", "timeout", "external_close"] as const)("joins an output flush before controller retirement permits a successor writer (%s)", async mode => {
|
||||
const root = await fs.mkdtemp(join(tmpdir(), "runnerd-detach-write-"));
|
||||
await fs.mkdir(join(root, "diagnostics"), { mode: 0o700 });
|
||||
const binary = defaultCapabilityRunnerdBinary();
|
||||
const artifact = readRunnerdArtifactBinding(binary);
|
||||
let core!: DurablePrpControlPlane;
|
||||
const start = DurablePrpControlPlane.prototype.start;
|
||||
const startSpy = vi.spyOn(DurablePrpControlPlane.prototype, "start").mockImplementation(function (port) {
|
||||
core = this;
|
||||
return start.call(this, port);
|
||||
});
|
||||
let release!: () => void;
|
||||
const barrier = new Promise<void>(resolveWrite => { release = resolveWrite; });
|
||||
let staged = false;
|
||||
const originalOpen = fs.open;
|
||||
const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => {
|
||||
const file = await originalOpen(...args);
|
||||
if (!staged && String(args[0]).includes(".control-plane-state.json.")) {
|
||||
const sync = file.sync.bind(file);
|
||||
file.sync = async () => { staged = true; await barrier; await sync(); };
|
||||
}
|
||||
return file;
|
||||
});
|
||||
let child: ChildProcess | undefined;
|
||||
let exit: Promise<void> | undefined;
|
||||
const closeSteps: string[] = [];
|
||||
let stopSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: binary,
|
||||
codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"),
|
||||
codexArgs: ["--state-file", join(root, "provider-state.json"), "--split-event-burst", "--split-event-prefix-count", "1"],
|
||||
stateDirectory: root,
|
||||
closeGraceMs: mode === "timeout" ? 200 : 1_500,
|
||||
...(mode === "external_close" ? { controlPlaneRegistration: async (authority: DurablePrpControlPlane) => {
|
||||
await authority.start();
|
||||
const stop = authority.stop.bind(authority);
|
||||
stopSpy = vi.spyOn(authority, "stop").mockImplementation(async () => {
|
||||
closeSteps.push("stop");
|
||||
release();
|
||||
await stop();
|
||||
});
|
||||
return { connectUrl: authority.connectUrl, release: () => { closeSteps.push("release"); } };
|
||||
} } : {}),
|
||||
runnerProcessLauncher: spec => {
|
||||
child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.environment, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const owned = child;
|
||||
let stderr = "";
|
||||
owned.stderr?.on("data", chunk => { stderr += String(chunk); });
|
||||
exit = new Promise(resolveExit => owned.once("exit", () => resolveExit()));
|
||||
const completion = new Promise<{ code: number | null; signal: string | null; stdout: string; stderr: string }>((resolveExit, reject) => {
|
||||
owned.once("error", reject);
|
||||
owned.once("exit", (code, signal) => resolveExit({ code, signal, stdout: "", stderr }));
|
||||
});
|
||||
return { child: owned, completion };
|
||||
},
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
let detached: Promise<void> | undefined;
|
||||
try {
|
||||
await bundle.transport.request("thread/start", { cwd: root, dynamicTools: [{
|
||||
name: "get_task_context", description: "Read fixture context.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}] });
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Exercise a held output flush." }] });
|
||||
await vi.waitFor(() => expect(staged).toBe(true), { timeout: 5_000 });
|
||||
if (mode === "external_close") {
|
||||
await bundle.transport.close().catch(error => {
|
||||
expect(error.message).toMatch(/journal drain timed out|did not durably suspend/);
|
||||
});
|
||||
expect(closeSteps.indexOf("release")).toBeGreaterThanOrEqual(0);
|
||||
expect(closeSteps.indexOf("release")).toBeLessThan(closeSteps.indexOf("stop"));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
detached = bundle.detachControllerForRestart().then(() => { settled = true; });
|
||||
await vi.waitFor(() => expect(core.activeRunnerConnectionCount()).toBe(0));
|
||||
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
|
||||
expect(settled).toBe(false);
|
||||
if (mode === "timeout") {
|
||||
await expect(detached).rejects.toThrow("journal drain timed out");
|
||||
await expect(bundle.detachControllerForRestart()).rejects.toThrow("journal drain timed out");
|
||||
await expect(bundle.transport.close()).rejects.toThrow("journal drain timed out");
|
||||
release();
|
||||
await core.drainPendingConnectionProcessing();
|
||||
} else {
|
||||
release();
|
||||
await detached;
|
||||
}
|
||||
const successor = new DurablePrpControlPlane({ stateDirectory: join(root, "control-plane"),
|
||||
identity: core.store.state.identity, expectedRunnerVersion: artifact.version, expectedRunnerDigest: artifact.digest });
|
||||
successor.queueCommand("session.snapshot", {}, "successor_command");
|
||||
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
|
||||
const persisted = JSON.parse(await fs.readFile(successor.store.path, "utf8"));
|
||||
expect(persisted.commands.some((command: { commandId: string }) => command.commandId === "successor_command")).toBe(true);
|
||||
} catch (error) {
|
||||
const diagnostic = await fs.readFile(join(root, "diagnostics", "runnerd.stderr.log"), "utf8").catch(() => "");
|
||||
throw new Error(`${String(error)}\n${diagnostic}`, { cause: error });
|
||||
} finally {
|
||||
release();
|
||||
await detached?.catch(() => undefined);
|
||||
openSpy.mockRestore();
|
||||
startSpy.mockRestore();
|
||||
await bundle.detachControllerForRestart().catch(() => undefined);
|
||||
const providerPid = bundle.evidence().codexPid;
|
||||
if (providerPid) {
|
||||
try { process.kill(providerPid, "SIGKILL"); } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
child?.kill("SIGKILL");
|
||||
await exit;
|
||||
await core?.drainPendingConnectionProcessing();
|
||||
stopSpy?.mockRestore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { codexSemanticToolSpecs, dynamicToolResponse } from "../drivers/codex/codex-driver-values.js";
|
||||
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
|
||||
|
||||
const runner = defaultCapabilityRunnerdBinary();
|
||||
const proxy = resolve("dist/cli/opencode-app-server-proxy.cjs");
|
||||
const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
|
||||
|
||||
it.skipIf(process.platform === "win32" || !existsSync(runner) || !existsSync(proxy))(
|
||||
"retains assigned discovery authority across Rust, the proxy, and HTTP MCP",
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-assigned-"));
|
||||
const workspace = join(root, "workspace");
|
||||
const executable = join(root, "fake-opencode-server.mjs");
|
||||
await mkdir(workspace);
|
||||
await copyFile(resolve("test/fixtures/fake-opencode-server.mjs"), executable);
|
||||
await chmod(executable, 0o755);
|
||||
const operation = "paperclip_search_assigned_tools";
|
||||
const seen: string[] = [];
|
||||
const { transport } = createRunnerdCodexTransport({
|
||||
provider: "opencode", runnerBinary: runner, stateDirectory: join(root, "state"), sourceCodexHome: "",
|
||||
providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath),
|
||||
opencodeCommand: executable, opencodeCommandSha256: digest(executable),
|
||||
opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy),
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" },
|
||||
});
|
||||
transport.setServerRequestHandler(async request => {
|
||||
expect(request.method).toBe("item/tool/call");
|
||||
const tool = request.params.tool;
|
||||
expect([operation, "paperclip_finish"]).toContain(tool);
|
||||
seen.push(String(tool));
|
||||
return dynamicToolResponse(tool === operation ? { tools: [] } : "Accepted completion.");
|
||||
});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await transport.request("thread/start", {
|
||||
cwd: workspace, model: "openrouter/deepseek/deepseek-v4.1-flash-1017",
|
||||
completionContract: { revision: "assigned-tools-v1", criterionIds: ["objective"] },
|
||||
dynamicTools: [{ name: operation, description: "Search assigned synthetic tools.", inputSchema: { type: "object" } }, ...codexSemanticToolSpecs()],
|
||||
});
|
||||
const completed = (async () => {
|
||||
for await (const notification of transport.notifications()) {
|
||||
if (notification.method === "turn/completed") return;
|
||||
if (notification.method === "turn/failed") throw new Error("Native OpenCode turn failed");
|
||||
}
|
||||
throw new Error("Native OpenCode notifications ended before completion");
|
||||
})();
|
||||
await transport.request("turn/start", { input: [{ type: "text", text: "Discover assigned synthetic tools." }] });
|
||||
await Promise.race([completed, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Native assigned-tool smoke timed out")), 20_000);
|
||||
})]);
|
||||
expect(seen).toEqual([operation, "paperclip_finish"]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
await transport.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
@@ -9,6 +9,7 @@ export * from "./index.js";
|
||||
export * from "./conformance/control-plane-port.js";
|
||||
export * from "./conformance/capability-semantic-conformance.js";
|
||||
export * from "./conformance/harness-driver.js";
|
||||
export { startOpenCodeMcpBridge } from "./drivers/opencode/mcp-bridge.js";
|
||||
export * from "./conformance/semantic-conformance.js";
|
||||
export * from "./mock-core/deterministic-harness-driver.js";
|
||||
export * from "./mock-core/mock-control-plane-adapter.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"commandDigest": "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9",
|
||||
"commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
@@ -49,7 +49,7 @@
|
||||
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
|
||||
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
|
||||
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
|
||||
"nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1",
|
||||
"nativeRuntimePromptsSourceSha256": "103b83066da72434602e9e461e6278dfb40bb993d2f3b49b2e93df81bbb08b72",
|
||||
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
|
||||
"harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412",
|
||||
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
|
||||
@@ -60,7 +60,7 @@
|
||||
"nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a",
|
||||
"providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c",
|
||||
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
|
||||
"runtimeRequestBridgeSourceSha256": "8ddd5d26854f4a153a9559ea8105aef95a3ec94f03209e9a797c95932c162e2a",
|
||||
"runtimeRequestBridgeSourceSha256": "e6c98128d98a4177c42d68a04c1f749554fd5ec844da1bbbdb3f682670189445",
|
||||
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
|
||||
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
|
||||
"guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"commandDigest": "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 62,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b",
|
||||
"permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5",
|
||||
"toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "17361d1782c0dcebb2b7972635f45c890f2a4081f8bc0ef2d2276550e0b91629",
|
||||
"semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103",
|
||||
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "3343df6362252d44d12b079ff78c8f6539d1544f3ce5ec36ff36db70ab0fac31",
|
||||
"semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9",
|
||||
"semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603",
|
||||
"semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
|
||||
"displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877",
|
||||
"displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e",
|
||||
"configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff",
|
||||
"configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994",
|
||||
"profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282",
|
||||
"profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571",
|
||||
"profileInstallationSourceSha256": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88",
|
||||
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
|
||||
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
|
||||
"nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce",
|
||||
"nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842",
|
||||
"nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1",
|
||||
"nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90",
|
||||
"harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412",
|
||||
"harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249",
|
||||
"harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94",
|
||||
"transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523",
|
||||
"nativeBackendSourceSha256": "e7b12d4418c7a71f5f2474b046697d3009eae3613b284bec381df5e9965a1c37",
|
||||
"nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44",
|
||||
"nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a",
|
||||
"providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c",
|
||||
"launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296",
|
||||
"runtimeRequestBridgeSourceSha256": "e6c98128d98a4177c42d68a04c1f749554fd5ec844da1bbbdb3f682670189445",
|
||||
"runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295",
|
||||
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
|
||||
"guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f",
|
||||
"nativeExecutionSourceSha256": "3a30d52bffad68c7f02349193be84761050eb99039fc0556c2b449891a36e918",
|
||||
"profileCompatibilitySourceSha256": "be60656e66d7f61a290f8d1c9a3c9ec511fb13b6541825a087b470e971ccc020",
|
||||
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
|
||||
"assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c",
|
||||
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
|
||||
"credentialBindingSourceSha256": "7586d44b47c83395498dc8806287cd3538ef58177ba7c5949f5766ed105a0161",
|
||||
"sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80",
|
||||
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
|
||||
"metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727",
|
||||
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0",
|
||||
"runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec",
|
||||
"runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8",
|
||||
"runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea",
|
||||
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
|
||||
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
|
||||
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
|
||||
}
|
||||
}
|
||||
@@ -295,7 +295,7 @@ describe("connection management with production default agents", () => {
|
||||
const ownBinding = await f.request("POST", `${path}/bind`, { targetType: "issue", targetId: f.issueId });
|
||||
expect(ownBinding, JSON.stringify(ownBinding)).toMatchObject({ status: 201 });
|
||||
expect(await f.request("POST", `${path}/unbind`, { targetType: "issue", targetId: f.issueId })).toMatchObject({ status: 200 });
|
||||
for (const target of [{ targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) {
|
||||
for (const target of [{ targetType: "project", targetId: f.projectId }, { targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) {
|
||||
expect(await f.request("POST", `${path}/bind`, target), JSON.stringify(target)).toMatchObject({ status: 403 });
|
||||
const [saved] = await server.db.insert(toolProfileBindings).values({ ...target, companyId: f.companyId, profileId: profile.body.id }).returning();
|
||||
expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit retained target" })).toMatchObject({ status: 403 });
|
||||
|
||||
@@ -353,7 +353,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
selfServiceUserId: connection.createdByUserId,
|
||||
});
|
||||
}
|
||||
if (["issue", "routine", "gateway"].includes(String(selected.targetType)) && text(selected.targetId)) bindingTargets.push({ targetType: String(selected.targetType), targetId: uuid(selected.targetId) });
|
||||
if (["project", "issue", "routine", "gateway"].includes(String(selected.targetType)) && text(selected.targetId)) bindingTargets.push({ targetType: String(selected.targetType), targetId: uuid(selected.targetId) });
|
||||
if (text(selected.routineId)) extraResources.push({ ...resource, routineId: uuid(selected.routineId) });
|
||||
if (selected.targetType === "project" && text(selected.targetId)) extraResources.push({ ...resource, projectId: String(selected.targetId) });
|
||||
if (text(selected.projectId)) extraResources.push({ ...resource, projectId: String(selected.projectId) });
|
||||
|
||||
@@ -97,12 +97,10 @@ export function connectionManagementService(db: Db) {
|
||||
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
||||
companyAgents.forEach(agent => expandedTargets.add(agent.id));
|
||||
}
|
||||
if (["issue", "routine", "gateway"].includes(binding.targetType)) {
|
||||
if (["project", "issue", "routine", "gateway"].includes(binding.targetType)) {
|
||||
await authorizeBindingTarget(actor, action, resource, binding, options);
|
||||
}
|
||||
if (binding.targetType === "project") {
|
||||
await authorize(actor, action, { ...resource, projectId: binding.targetId }, { ...options, expandedProfile: true, gatewayContextChecked: options.gatewayContextChecked || Boolean(resource.gatewayId) });
|
||||
}
|
||||
|
||||
}
|
||||
const linkedGateways = await db.select({ id: toolMcpGateways.id }).from(toolMcpGateways).where(and(eq(toolMcpGateways.companyId, resource.companyId), eq(toolMcpGateways.profileId, profile.id)));
|
||||
for (const gateway of linkedGateways) await authorizeBindingTarget(actor, action, resource, { targetType: "gateway", targetId: gateway.id }, options);
|
||||
@@ -250,7 +248,12 @@ export function connectionManagementService(db: Db) {
|
||||
options: NonNullable<Parameters<typeof authorize>[3]> = {}): Promise<ConnectionManagementContext> {
|
||||
let resolved = { ...resource };
|
||||
const checkOptions = { ...options, expandedProfile: true };
|
||||
if (binding.targetType === "issue") resolved.issueId = binding.targetId;
|
||||
if (binding.targetType === "project") {
|
||||
resolved.projectId = binding.targetId;
|
||||
const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
||||
checkOptions.targetAgentIds = [...new Set([...(options.targetAgentIds ?? []), ...rows.map(row => row.id)])];
|
||||
}
|
||||
else if (binding.targetType === "issue") resolved.issueId = binding.targetId;
|
||||
else if (binding.targetType === "routine") resolved.routineId = binding.targetId;
|
||||
else if (binding.targetType === "gateway") {
|
||||
const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, binding.targetId), eq(toolMcpGateways.companyId, resource.companyId)));
|
||||
|
||||
@@ -971,6 +971,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
|
||||
nativeToolContractFingerprint:
|
||||
nativeToolContractFingerprintForTarget(
|
||||
executionTarget?.kind ?? "local",
|
||||
nativeExecution!.provider.kind,
|
||||
),
|
||||
...(lockedProfile.sessionCheckpoint != null
|
||||
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
|
||||
import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js";
|
||||
import { startOpenCodeMcpBridge } from "../../vendor/paperclip-runner/testing.js";
|
||||
|
||||
function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor {
|
||||
return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk };
|
||||
@@ -18,6 +19,93 @@ describe("assigned MCP runner tools", () => {
|
||||
const searchName = "paperclip_search_assigned_tools";
|
||||
const callName = "paperclip_call_assigned_tool";
|
||||
|
||||
it.each(["page", "escaped schema"])("keeps on-demand %s results valid through the OpenCode MCP bridge", async variant => {
|
||||
const schema = { type: "object", description: variant === "page" ? "x".repeat(20_000) : "\"\\\u0000🙂".repeat(30_000) };
|
||||
const f = fixture(Array.from({ length: variant === "page" ? 5 : 1 }, (_, i) => ({
|
||||
...descriptor(`large_${i}`), parametersSchema: schema,
|
||||
})));
|
||||
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
|
||||
const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) });
|
||||
let id = 0;
|
||||
const search = async (args: Record<string, unknown>) => {
|
||||
const response = await fetch(bridge.url, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name: searchName, arguments: args } }),
|
||||
});
|
||||
const rpc = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } };
|
||||
expect(rpc.result.isError).not.toBe(true);
|
||||
return JSON.parse(rpc.result.content[0]!.text);
|
||||
};
|
||||
try {
|
||||
let page = await search({ query: "", limit: 20 });
|
||||
if (variant === "page") {
|
||||
const seen = [...page.tools];
|
||||
while (page.nextOffset !== null) {
|
||||
page = await search({ query: "", offset: page.nextOffset, limit: 20 });
|
||||
seen.push(...page.tools);
|
||||
}
|
||||
expect(seen).toHaveLength(5);
|
||||
expect(seen.every(tool => JSON.stringify(tool.inputSchema) === JSON.stringify(schema))).toBe(true);
|
||||
} else {
|
||||
const name = page.tools[0]!.inputSchemaRef;
|
||||
expect(name).toBeTypeOf("string");
|
||||
let offset: number | null = 0;
|
||||
let reconstructed = "";
|
||||
do {
|
||||
const chunk = await search({ query: "", schemaTool: name, schemaOffset: offset });
|
||||
reconstructed += chunk.schemaJson;
|
||||
offset = chunk.nextSchemaOffset;
|
||||
} while (offset !== null);
|
||||
expect(JSON.parse(reconstructed)).toEqual(schema);
|
||||
}
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 741])("uses bounded on-demand delivery for %i tools even when the direct catalog fits", async count => {
|
||||
const f = fixture(Array.from({ length: count }, (_, i) => descriptor(`app.operation_${i}`)));
|
||||
const direct = await createAssignedMcpTools(f);
|
||||
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
|
||||
const fits = vi.fn(() => true);
|
||||
expect(assigned.definitions(fits).map(tool => tool.name)).toEqual([searchName, callName]);
|
||||
expect(fits).toHaveBeenCalledExactlyOnceWith(assigned.definitions());
|
||||
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as { tools: Array<{ name: string }> };
|
||||
expect(page.tools[0]!.name).toBe(direct.definitions().map(tool => String(tool.name)).sort()[0]);
|
||||
await assigned.execute({ tool: callName, arguments: { name: page.tools[0]!.name, arguments: { query: "fixture" }, gatewayPublicId: "untrusted", bearerToken: "untrusted" } });
|
||||
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken }));
|
||||
f.listToolsForNamedGateway.mockResolvedValue([]);
|
||||
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).resolves.toEqual({ tools: [], nextOffset: null });
|
||||
});
|
||||
|
||||
it("keeps on-demand aliases pinned, mode restricted, revocable and approval gated", async () => {
|
||||
const read = descriptor("mcp.first:search");
|
||||
const write = descriptor("mcp.second:search", "write");
|
||||
const f = fixture([read, write]);
|
||||
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
|
||||
const page = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string; description: string }> };
|
||||
expect(new Set(page.tools.map(tool => tool.name)).size).toBe(2);
|
||||
const writeAlias = page.tools.find(tool => tool.description.includes(write.name))!.name;
|
||||
const call = { tool: callName, arguments: { name: writeAlias, arguments: {} } };
|
||||
await expect(assigned.execute(call, "planning")).rejects.toThrow("paperclip_runner_tool_mode_denied");
|
||||
await expect(assigned.execute(call, "ask")).rejects.toThrow("paperclip_runner_tool_mode_denied");
|
||||
expect(f.executeTool).not.toHaveBeenCalled();
|
||||
const denied = new ToolGatewayHttpError(403, "Approval required", "approval_required");
|
||||
f.executeTool.mockRejectedValueOnce(denied);
|
||||
await expect(assigned.execute(call)).rejects.toBe(denied);
|
||||
f.listToolsForNamedGateway.mockResolvedValue([read, descriptor("newly.granted")]);
|
||||
const refreshed = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string }> };
|
||||
expect(refreshed.tools).toHaveLength(1);
|
||||
expect(refreshed.tools[0]!.name).not.toBe(writeAlias);
|
||||
// Execution still goes through the original gateway, which reauthorizes
|
||||
// each call; discovery never grants an added tool to this pinned session.
|
||||
const revoked = new ToolGatewayHttpError(403, "Revoked", "connection_revoked");
|
||||
f.executeTool.mockRejectedValueOnce(revoked);
|
||||
await expect(assigned.execute(call)).rejects.toBe(revoked);
|
||||
await expect(assigned.execute({ tool: callName, arguments: { name: "newly.granted", arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
|
||||
});
|
||||
|
||||
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
|
||||
const read = descriptor("memory.search");
|
||||
const write = descriptor("memory.store", "write");
|
||||
|
||||
@@ -86,6 +86,8 @@ export async function createAssignedMcpTools(input: {
|
||||
gatewayPublicId: string;
|
||||
bearerToken: string;
|
||||
workMode?: WorkMode;
|
||||
/** Bound provider grammar complexity independently of protocol byte capacity. */
|
||||
delivery?: "on_demand";
|
||||
}) {
|
||||
const listed = await input.gateway.listToolsForNamedGateway({
|
||||
gatewayPublicId: input.gatewayPublicId,
|
||||
@@ -103,6 +105,11 @@ export async function createAssignedMcpTools(input: {
|
||||
tools.set(name, descriptor);
|
||||
}
|
||||
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
|
||||
// OpenCode's HTTP bridge returns at most 64K JSON characters. Leave room
|
||||
// for the envelope and for JSON escaping when returning a schema chunk.
|
||||
// Other harnesses retain the larger PRP page and chunk budgets.
|
||||
const searchPageBytes = input.delivery === "on_demand" ? 32 * 1024 : SEARCH_PAGE_BYTES;
|
||||
const schemaChunkCharacters = input.delivery === "on_demand" ? 16 * 1024 : SCHEMA_CHUNK_CHARACTERS;
|
||||
|
||||
async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) {
|
||||
const args = object(argumentsValue);
|
||||
@@ -132,7 +139,7 @@ export async function createAssignedMcpTools(input: {
|
||||
const selected = authorized.find(([name]) => name === args.schemaTool);
|
||||
if (!selected) throw new Error("assigned_mcp_tool_unknown");
|
||||
const schema = JSON.stringify(selected[1].parametersSchema);
|
||||
const end = schemaOffset + SCHEMA_CHUNK_CHARACTERS;
|
||||
const end = schemaOffset + schemaChunkCharacters;
|
||||
return {
|
||||
name: selected[0], schemaJson: schema.slice(schemaOffset, end),
|
||||
nextSchemaOffset: end < schema.length ? end : null,
|
||||
@@ -144,13 +151,13 @@ export async function createAssignedMcpTools(input: {
|
||||
const page: ToolDefinition[] = [];
|
||||
for (const [name, tool] of matches.slice(offset, offset + limit)) {
|
||||
let next = definition(name, tool);
|
||||
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > SEARCH_PAGE_BYTES) {
|
||||
if (Buffer.byteLength(JSON.stringify([next]), "utf8") > searchPageBytes) {
|
||||
// One large schema must not block browsing the tools after it. Expose
|
||||
// a reference whose schema can be fetched in bounded, reauthorized
|
||||
// chunks, without weakening the gateway's argument validation.
|
||||
next = { name, description: String(next.description).slice(0, 2000), inputSchemaRef: name };
|
||||
}
|
||||
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > SEARCH_PAGE_BYTES) {
|
||||
if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > searchPageBytes) {
|
||||
break;
|
||||
}
|
||||
page.push(next);
|
||||
@@ -161,9 +168,9 @@ export async function createAssignedMcpTools(input: {
|
||||
return {
|
||||
definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] {
|
||||
const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool));
|
||||
if (!fits || fits(direct)) return direct;
|
||||
if (input.delivery !== "on_demand" && (!fits || fits(direct))) return direct;
|
||||
const compact = structuredClone(ON_DEMAND_TOOLS);
|
||||
if (!fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
if (fits && !fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded");
|
||||
return compact;
|
||||
},
|
||||
has(name: string): boolean {
|
||||
|
||||
@@ -1471,6 +1471,7 @@ describe("remote provider pack manifest", () => {
|
||||
try {
|
||||
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
|
||||
runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true,
|
||||
...(provider === "opencode" ? { runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "assigned-fixture", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1/mcp/gateways/assigned-fixture", PAPERCLIP_NATIVE_MCP_TOKEN: "private-fixture" } } : {}),
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
|
||||
environmentId: "environment", leaseId: "lease", providerKey: "daytona",
|
||||
effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never });
|
||||
@@ -1478,8 +1479,13 @@ describe("remote provider pack manifest", () => {
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
|
||||
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
};
|
||||
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
|
||||
if (provider === "opencode") {
|
||||
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-fixture", bearerToken: "private-fixture", delivery: "on_demand" }));
|
||||
for (const name of ["PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN"]) expect(transport.environment?.[name]).toBeUndefined();
|
||||
}
|
||||
if (claude) {
|
||||
expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined }));
|
||||
const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js");
|
||||
@@ -7800,7 +7806,7 @@ describe("bounded stopped instruction collection", () => {
|
||||
describe("native warm session supervision", () => {
|
||||
it.each(["collect", "close-failure", "stopped", "destroyed", "missing-lease", "foreign-run", "foreign-environment", "deleted-environment", "claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical", "claim-config", "claim-uncertain"])("composes remote warm ownership with real DB successor leases and current-run collection: %s", async ending => {
|
||||
const tables = await import("@paperclipai/db");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { eq, sql } = await import("drizzle-orm");
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const { promisify } = await import("node:util");
|
||||
const { execFile } = await import("node:child_process");
|
||||
@@ -7813,7 +7819,11 @@ describe("native warm session supervision", () => {
|
||||
const home = await realpath(await mkdtemp(join(tmpdir(), "warm-remote-db-")));
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
const database = await startEmbeddedPostgresTestDatabase("warm-remote-db-");
|
||||
const { resolveExternalTestDatabaseUrl } = await import("../../__tests__/helpers/external-test-database.js");
|
||||
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
|
||||
const database = externalUrl
|
||||
? { connectionString: externalUrl, cleanup: () => tables.closeRegisteredClients(externalUrl) }
|
||||
: await startEmbeddedPostgresTestDatabase("warm-remote-db-");
|
||||
const db = tables.createDb(database.connectionString);
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), environmentId = randomUUID(), issueId = randomUUID();
|
||||
const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js");
|
||||
@@ -8049,6 +8059,9 @@ describe("native warm session supervision", () => {
|
||||
finally {
|
||||
const cleanupFailures: unknown[] = [];
|
||||
try { await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" }); } catch (error) { cleanupFailures.push(error); }
|
||||
if (externalUrl) {
|
||||
try { await db.execute(sql`TRUNCATE TABLE ${tables.companies}, ${tables.environments}, ${tables.authUsers} CASCADE`); } catch (error) { cleanupFailures.push(error); }
|
||||
}
|
||||
try { await database.cleanup(); } catch (error) { cleanupFailures.push(error); }
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
||||
try {
|
||||
@@ -11828,7 +11841,10 @@ describe("runnerd provider runtime wiring", () => {
|
||||
} finally {owner.mockRestore();await rm(home,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
it("keeps assigned MCP credentials on the control plane for remote Codex", async () => {
|
||||
it.each(["codex", "opencode"] as const)("keeps assigned MCP credentials on the control plane for %s", async (providerKind) => {
|
||||
const selectedExecution: NativeExecutionInput = providerKind === "opencode"
|
||||
? { ...execution, provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" } }
|
||||
: execution;
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.test");
|
||||
const exporter = new InMemorySpanExporter();
|
||||
@@ -11841,13 +11857,13 @@ describe("runnerd provider runtime wiring", () => {
|
||||
state.createTransport.mockClear();
|
||||
state.toolAuthorityDefinitions.mockClear();
|
||||
await createRunnerdBackend({
|
||||
db: leaseDb(execution), execution, runnerInstanceId: "runner-assigned-mcp",
|
||||
db: leaseDb(selectedExecution), execution: selectedExecution, runnerInstanceId: "runner-assigned-mcp",
|
||||
runnerEnvironment: {
|
||||
PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned",
|
||||
PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-test",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token",
|
||||
},
|
||||
runnerExecutionTarget: {
|
||||
runnerExecutionTarget: providerKind === "opencode" ? undefined : {
|
||||
kind: "remote", transport: "sandbox", providerKey: "daytona",
|
||||
leaseId: "lease-assigned-mcp", remoteCwd: "/home/daytona/paperclip-workspace",
|
||||
runner: { execute: vi.fn() },
|
||||
@@ -11855,6 +11871,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||
});
|
||||
expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({
|
||||
gatewayPublicId: "assigned-test", bearerToken: "private-run-token",
|
||||
...(providerKind === "opencode" ? { delivery: "on_demand" } : {}),
|
||||
}));
|
||||
expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools }));
|
||||
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
|
||||
|
||||
@@ -5563,7 +5563,7 @@ function nativeSessionConfigDigest(
|
||||
// changes so an older thread is rotated instead of falsely resuming
|
||||
// without newly required tools.
|
||||
nativeToolContractFingerprint:
|
||||
nativeToolContractFingerprintForTarget(executionTargetKind),
|
||||
nativeToolContractFingerprintForTarget(executionTargetKind, execution.provider.kind),
|
||||
runtimeContract: nativeRuntimeContractForProvider(execution.provider),
|
||||
}),
|
||||
)
|
||||
@@ -11828,10 +11828,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
if (nativeReview && !await getNativeReviewAssignment(input.db, {
|
||||
...input.execution.binding, contextSnapshot: nativeReview,
|
||||
})) throw new Error("native_review_assignment_no_longer_available");
|
||||
// Remote Codex already sends dynamic tool calls over authenticated PRP. Keep
|
||||
// Codex and OpenCode send dynamic tool calls over authenticated PRP. Keep
|
||||
// the assigned gateway on the control plane instead of asking the sandbox to
|
||||
// reach the host's HTTP origin (which may be private or loopback-only).
|
||||
const relayAssignedMcp = Boolean(pluginTask) || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
|
||||
const relayAssignedMcp = Boolean(pluginTask) || input.execution.provider.kind === "opencode" || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
|
||||
const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL;
|
||||
const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN;
|
||||
const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME;
|
||||
@@ -11853,6 +11853,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
gatewayPublicId: assignedGatewayPublicId!,
|
||||
bearerToken: assignedMcpToken,
|
||||
workMode: input.execution.task.workMode,
|
||||
...(input.execution.provider.kind === "opencode" ? { delivery: "on_demand" as const } : {}),
|
||||
}))
|
||||
: undefined),
|
||||
]);
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
startEmbeddedPostgresTestDatabase as startEmbeddedDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import {
|
||||
canonicalNativeRuntimeContextDigest,
|
||||
@@ -72,6 +72,20 @@ import {
|
||||
selectNativeSessionResumeRun,
|
||||
} from "./native-session-resume.js";
|
||||
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
||||
|
||||
const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL");
|
||||
|
||||
async function startEmbeddedPostgresTestDatabase(prefix: string) {
|
||||
const externalUrl = externalTestDatabaseUrl;
|
||||
if (!externalUrl) return startEmbeddedDatabase(prefix);
|
||||
const { closeRegisteredClients } = await import("@paperclipai/db");
|
||||
return { connectionString: externalUrl, cleanup: async () => {
|
||||
const db = createDb(externalUrl);
|
||||
try { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }
|
||||
finally { await closeRegisteredClients(externalUrl); }
|
||||
} };
|
||||
}
|
||||
|
||||
const companyId = "10000000-0000-4000-8000-000000000001";
|
||||
const issueId = "20000000-0000-4000-8000-000000000002";
|
||||
@@ -283,12 +297,13 @@ it("wires exact-session recovery and guarded selected identity into heartbeat pe
|
||||
});
|
||||
|
||||
const embeddedSupport = await getEmbeddedPostgresTestSupport();
|
||||
const databaseSupported = Boolean(externalTestDatabaseUrl) || embeddedSupport.supported;
|
||||
const recoveryFakeCodex = resolve(
|
||||
import.meta.dirname,
|
||||
"../../../../packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs",
|
||||
);
|
||||
|
||||
(embeddedSupport.supported && existsSync(defaultCapabilityRunnerdBinary())
|
||||
(databaseSupported && existsSync(defaultCapabilityRunnerdBinary())
|
||||
? it
|
||||
: it.skip)(
|
||||
"keeps actual normalized driver events separate from raw maintenance receipts",
|
||||
@@ -520,7 +535,7 @@ const recoveryFakeCodex = resolve(
|
||||
30_000,
|
||||
);
|
||||
|
||||
(embeddedSupport.supported &&
|
||||
(databaseSupported &&
|
||||
existsSync(defaultCapabilityRunnerdBinary()) &&
|
||||
existsSync(recoveryFakeCodex)
|
||||
? it
|
||||
@@ -1062,7 +1077,7 @@ const recoveryFakeCodex = resolve(
|
||||
60_000,
|
||||
);
|
||||
|
||||
(embeddedSupport.supported ? describe : describe.skip)(
|
||||
(databaseSupported ? describe : describe.skip)(
|
||||
"native session recovery database orchestration",
|
||||
() => {
|
||||
it("uses scoped latest checkpoints, preserves progress barriers, and guards bootstrap persistence after the row lock", async () => {
|
||||
@@ -1825,6 +1840,23 @@ describe("rebindNativeSessionCheckpoint", () => {
|
||||
|
||||
|
||||
|
||||
it.each(["local", "remote"] as const)("refreshes only OpenCode's former direct MCP catalog on %s", target => {
|
||||
const legacy = nativeToolContractFingerprintForTarget(target);
|
||||
expect(nativeToolContractFingerprintForTarget(target, "codex")).toBe(legacy);
|
||||
expect(nativeToolContractFingerprintForTarget(target, "acpx")).toBe(legacy);
|
||||
const current = nativeToolContractFingerprintForTarget(target, "opencode");
|
||||
expect(current).not.toBe(legacy);
|
||||
const shortenedCatalog = target === "local"
|
||||
? "sha256:2ae35faccbb63cfe009d94170ef1ef13f0d57e120af6160540598a3ec72001d7"
|
||||
: "sha256:ee5285ca5118b8468c0afd1df4ef9c05d85b0934e882bccd0b193dc8722dbb7a";
|
||||
expect(current).not.toBe(shortenedCatalog);
|
||||
const currentExecution = { ...execution(currentRunId), provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, session: { ...execution(currentRunId).session, driverKind: "opencode_server" } } as NativeExecutionInput;
|
||||
const previousExecution = { ...execution(previousRunId), provider: currentExecution.provider, session: currentExecution.session };
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: shortenedCatalog, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: legacy, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull();
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: current, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("binds the provider tool catalog to the local or remote execution target", () => {
|
||||
const remoteFingerprint = nativeToolContractFingerprintForTarget("remote");
|
||||
expect(remoteFingerprint).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT);
|
||||
|
||||
@@ -29,6 +29,7 @@ export function nativeRuntimeContractForProvider(
|
||||
*/
|
||||
export function nativeToolContractFingerprintForTarget(
|
||||
executionTargetKind: NativeToolExecutionTargetKind,
|
||||
providerKind?: NativeExecutionInput["provider"]["kind"],
|
||||
): string {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(
|
||||
@@ -47,6 +48,9 @@ export function nativeToolContractFingerprintForTarget(
|
||||
structuredHumanInput:
|
||||
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
|
||||
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
|
||||
...(providerKind === "opencode"
|
||||
? { opencodeAssignedMcpTools: "server_gateway_prp_on_demand_exact_names.v2" }
|
||||
: {}),
|
||||
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
|
||||
chatTaskTools: "durable_task_binding_and_accepted_origin.v1",
|
||||
githubResponses: "bound_working_comment_progress_and_final.v1",
|
||||
@@ -482,7 +486,7 @@ export function rebindNativeSessionCheckpoint(input: {
|
||||
const previousProfile = record(input.previousRun.runnerProfileJson);
|
||||
if (
|
||||
previousProfile.nativeToolContractFingerprint !==
|
||||
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local")
|
||||
nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local", input.currentExecution.provider.kind)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const sourceUrl = new URL(
|
||||
);
|
||||
const runnerTesting = await import(sourceUrl.href) as RunnerTestingModule;
|
||||
|
||||
export const startOpenCodeMcpBridge:
|
||||
RunnerTestingModule["startOpenCodeMcpBridge"] =
|
||||
runnerTesting.startOpenCodeMcpBridge;
|
||||
|
||||
export const CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS:
|
||||
RunnerTestingModule["CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS"] =
|
||||
runnerTesting.CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS;
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
|
||||
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
|
||||
import { healthApi } from "../api/health";
|
||||
import {
|
||||
resolveArchivedCompanyBounce,
|
||||
@@ -178,6 +179,7 @@ export function Layout() {
|
||||
const scrollMemory = useRef(new NavigationScrollMemory());
|
||||
const activeScrollKey = useRef<string>(location.key);
|
||||
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
|
||||
const mobileViewportRef = useMobileVisualViewport(isMobile);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -588,8 +590,10 @@ export function Layout() {
|
||||
return (
|
||||
<ChatSetupSidebarProvider>
|
||||
<div
|
||||
ref={mobileViewportRef}
|
||||
data-mobile={isMobile ? "true" : undefined}
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-(--sz-safe-top)",
|
||||
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
|
||||
// overflow-x-clip on mobile keeps a stray wide descendant from making the
|
||||
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
|
||||
// computed as visible, so native body scroll + the sticky breadcrumb keep
|
||||
@@ -609,7 +613,7 @@ export function Layout() {
|
||||
<DevRestartBanner devServer={health?.devServer} />
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
"app-main-row min-h-0 flex-1",
|
||||
isMobile ? "w-full" : "flex overflow-clip",
|
||||
)}
|
||||
>
|
||||
@@ -663,12 +667,13 @@ export function Layout() {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col",
|
||||
"app-content-column flex min-w-0 flex-col",
|
||||
isMobile ? "w-full" : "h-full flex-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"app-breadcrumb",
|
||||
isMobile &&
|
||||
"sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||
)}
|
||||
@@ -681,7 +686,7 @@ export function Layout() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<div className={cn("app-content-row", isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main
|
||||
id="main-content"
|
||||
ref={mainContentRef}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
|
||||
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
|
||||
import { healthApi } from "../api/health";
|
||||
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||
import { useOptionalToastActions } from "../context/ToastContext";
|
||||
@@ -143,6 +144,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
const scrollMemory = useRef(new NavigationScrollMemory());
|
||||
const activeScrollKey = useRef<string>(location.key);
|
||||
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
|
||||
const mobileViewportRef = useMobileVisualViewport(isMobile);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -574,8 +576,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
return (
|
||||
<ChatSetupSidebarProvider>
|
||||
<div
|
||||
ref={mobileViewportRef}
|
||||
data-mobile={isMobile ? "true" : undefined}
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-(--sz-safe-top)",
|
||||
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
|
||||
// overflow-x-clip on mobile keeps a stray wide descendant from making the
|
||||
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
|
||||
// computed as visible, so native body scroll + the sticky breadcrumb keep
|
||||
@@ -591,7 +595,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
</a>
|
||||
<WorktreeBanner />
|
||||
<DevRestartBanner devServer={health?.devServer} />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
|
||||
<div className={cn("app-main-row min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -656,9 +660,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
</SecondarySidebar>
|
||||
) : null}
|
||||
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div className={cn("app-content-column flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
"app-breadcrumb",
|
||||
!isMobile && useStreamlinedTaskDetailShell && "hidden",
|
||||
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||
)}
|
||||
@@ -672,6 +677,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"app-content-row",
|
||||
isMobile ? "block" : "flex flex-1 min-h-0",
|
||||
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
|
||||
)}>
|
||||
|
||||
@@ -3535,7 +3535,7 @@ describe("TaskChatThread composer alignment", () => {
|
||||
|
||||
const dock = container
|
||||
.querySelector('[data-testid="mock-editor"]')
|
||||
?.closest("div.sticky") as HTMLElement | null;
|
||||
?.closest('[data-testid="task-chat-composer-dock"]') as HTMLElement | null;
|
||||
|
||||
expect(dock?.className).toContain("w-full");
|
||||
expect(dock?.className).toContain("max-w-(--tc-shell-max-w)");
|
||||
@@ -4319,26 +4319,22 @@ describe("TaskChatThread Paperclip Runner queue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
|
||||
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
|
||||
describe("TaskChatThread mobile composer dock", () => {
|
||||
it("scrolls messages independently of the composer on mobile", () => {
|
||||
sidebarState.isMobile = true;
|
||||
|
||||
renderThread({
|
||||
comments: [],
|
||||
comments: createLongThreadComments(),
|
||||
onAdd: async () => {},
|
||||
draftKey: "task-chat-draft:issue-mobile",
|
||||
});
|
||||
|
||||
const dock = container
|
||||
.querySelector('[data-testid="mock-editor"]')
|
||||
?.closest("div.sticky") as HTMLElement | null;
|
||||
|
||||
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
|
||||
const scroller = container.querySelector('[data-testid="task-chat-scroller"]');
|
||||
expect(dock).not.toBeNull();
|
||||
// Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav
|
||||
// height while the nav is on screen) — NOT the raw safe-area dock, which is
|
||||
// what let the nav occlude the action row before PAP-495.
|
||||
expect(dock?.className).toContain("bottom-(--tc-composer-bottom)");
|
||||
expect(dock?.className).not.toContain("bottom-(--sz-calc-8)");
|
||||
expect(scroller).not.toBeNull();
|
||||
expect(scroller?.textContent).toContain("Thread message 1");
|
||||
expect(scroller?.contains(dock)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@ import {
|
||||
useRunnerGoalControl,
|
||||
} from "@/components/task-chat/RunnerGoalWidget";
|
||||
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
|
||||
import { TaskChatWindowScroll } from "@/components/task-chat/useWindowAutoFollow";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
|
||||
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
|
||||
@@ -3040,25 +3039,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
|
||||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||||
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
|
||||
<TaskChatWindowScroll
|
||||
contentKey={isMobile ? autoFollowContentKey : 0}
|
||||
enabled={isMobile && historyRevealed}
|
||||
/>
|
||||
<TaskChatPresentationProvider
|
||||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
isMobile && !historyRevealed && "task-chat-history-pending",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
(!isMobile || !historyRevealed) && "min-h-0 flex-1",
|
||||
"relative flex min-h-0 flex-1 flex-col",
|
||||
)}
|
||||
aria-busy={!historyRevealed}
|
||||
>
|
||||
@@ -3093,8 +3086,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<div
|
||||
data-testid="task-chat-history-content"
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "min-h-0 flex-1",
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
!historyRevealed && "invisible",
|
||||
)}
|
||||
inert={!historyRevealed}
|
||||
@@ -3103,9 +3095,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<>
|
||||
{items.length === 0 && !tailRunId && !optimisticRunnerStartup ? (
|
||||
<div
|
||||
className={
|
||||
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
|
||||
}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
<div
|
||||
@@ -3241,7 +3231,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
}
|
||||
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
|
||||
className={isMobile ? undefined : "pt-3"}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -856,7 +856,7 @@ describe("TaskChatComposer", () => {
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
|
||||
|
||||
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
|
||||
expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)");
|
||||
expect(editable().dataset.contentClassName).toContain("max-h-(--tc-composer-editor-max-height)");
|
||||
});
|
||||
|
||||
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
|
||||
|
||||
@@ -1375,7 +1375,7 @@ export function TaskChatComposer({
|
||||
className={cn(disabled && "opacity-60")}
|
||||
contentClassName={
|
||||
mobile
|
||||
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
|
||||
? "max-h-(--tc-composer-editor-max-height) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
|
||||
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ interface TaskChatComposerDockProps {
|
||||
concealed?: boolean;
|
||||
}
|
||||
|
||||
/** The task thread and its Storybook phone previews share the same composer spacing. */
|
||||
/** A normal flex footer beside the message scroller, on desktop and mobile. */
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
|
||||
return <div
|
||||
data-testid="task-chat-composer-dock"
|
||||
@@ -16,14 +16,11 @@ export function TaskChatComposerDock({ children, mobile, streamlined, concealed
|
||||
inert={concealed}
|
||||
aria-hidden={concealed || undefined}
|
||||
className={cn(
|
||||
"sticky flex max-w-(--tc-shell-max-w) flex-col gap-2",
|
||||
"flex max-w-(--tc-shell-max-w) shrink-0 flex-col gap-2",
|
||||
concealed && "invisible",
|
||||
mobile
|
||||
? cn(
|
||||
"mobile-nav-composer bottom-(--tc-composer-bottom) z-20 w-auto px-1 pb-1",
|
||||
streamlined ? "-mx-2" : "mx-2",
|
||||
)
|
||||
: "bottom-0 z-10 mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
|
||||
? cn("w-auto px-1 pb-1", streamlined ? "-mx-2" : "mx-2")
|
||||
: "mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
|
||||
streamlined && "md:px-0 md:pb-0",
|
||||
(!streamlined || mobile) &&
|
||||
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:bg-transparent dark:backdrop-blur-none dark:supports-[backdrop-filter]:bg-transparent",
|
||||
|
||||
@@ -435,7 +435,7 @@ export function TaskChatThreadView({
|
||||
|
||||
// Cheap content signature so streaming growth (text lengthening without the
|
||||
// item count changing) still advances the auto-follow key. Shared by the
|
||||
// desktop scroller above and the mobile window-scroll follow (TaskChatThread).
|
||||
// shared message scroller on desktop and mobile (TaskChatThread).
|
||||
function signatureOf(it: TaskChatItem): number {
|
||||
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
|
||||
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
|
||||
|
||||
@@ -125,6 +125,36 @@ describe("TaskMessageScroller", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not measure historical rows on each streaming update while following the bottom", () => {
|
||||
let initialized = false;
|
||||
let rowMeasurements = 0;
|
||||
const update = (tick: number) => flushSync(() => root.render(
|
||||
<TaskMessageScroller contentKey={tick}>
|
||||
<div ref={(node) => {
|
||||
if (node && !initialized) {
|
||||
fakeGeometry(node.parentElement!, { scrollHeight: 20000, clientHeight: 400 });
|
||||
initialized = true;
|
||||
}
|
||||
}}>
|
||||
{Array.from({ length: 200 }, (_, index) => <div key={index} data-thread-anchor={`history-${index}`} ref={(node) => {
|
||||
if (node) node.getBoundingClientRect = () => {
|
||||
rowMeasurements++;
|
||||
const top = index * 100 - scroller().scrollTop;
|
||||
return { top, bottom: top + 100, height: 100 } as DOMRect;
|
||||
};
|
||||
}}>History {index}</div>)}
|
||||
<p>Live response {tick}</p>
|
||||
</div>
|
||||
</TaskMessageScroller>,
|
||||
));
|
||||
update(0);
|
||||
rowMeasurements = 0;
|
||||
for (let tick = 1; tick <= 30; tick++) update(tick);
|
||||
expect(container.textContent).toContain("Live response 30");
|
||||
expect(scroller().scrollTop).toBe(20000);
|
||||
expect(rowMeasurements).toBe(0);
|
||||
});
|
||||
|
||||
it("applies same-task hash changes and restores each history entry without remounting", async () => {
|
||||
let initialized = false;
|
||||
function navigate(key: string, hash: string, restore = false) {
|
||||
@@ -178,6 +208,42 @@ describe("TaskMessageScroller", () => {
|
||||
expect(scroller().scrollTop).toBe(150);
|
||||
});
|
||||
|
||||
it("restores bottom-follow on Back after the viewport and message heights change", () => {
|
||||
const configured = new WeakSet<HTMLElement>();
|
||||
function mount(restore: boolean, scrollHeight: number, clientHeight: number) {
|
||||
flushSync(() => root.render(
|
||||
<TaskChatScrollNavigation.Provider value={{ key: "rotated-bottom-entry", hash: "", restore }}>
|
||||
<TaskMessageScroller contentKey={scrollHeight}>
|
||||
<div ref={(node) => {
|
||||
const viewport = node?.parentElement;
|
||||
if (viewport && !configured.has(viewport)) {
|
||||
fakeGeometry(viewport, { scrollHeight, clientHeight });
|
||||
let top = 0;
|
||||
Object.defineProperty(viewport, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => top,
|
||||
set: (value: number) => {
|
||||
top = Math.max(0, Math.min(value, viewport.scrollHeight - viewport.clientHeight));
|
||||
},
|
||||
});
|
||||
configured.add(viewport);
|
||||
}
|
||||
}}>Latest response</div>
|
||||
</TaskMessageScroller>
|
||||
</TaskChatScrollNavigation.Provider>,
|
||||
));
|
||||
}
|
||||
mount(false, 1000, 400);
|
||||
flushSync(() => root.render(null));
|
||||
// Rotation changed wrapping and the available message viewport while away.
|
||||
mount(true, 2000, 500);
|
||||
expect(scroller().scrollTop).toBe(1500);
|
||||
expect(pill()).toBeNull();
|
||||
Object.defineProperty(scroller(), "scrollHeight", { value: 2400, configurable: true });
|
||||
mount(true, 2400, 500);
|
||||
expect(scroller().scrollTop).toBe(1900);
|
||||
});
|
||||
|
||||
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
|
||||
render();
|
||||
const el = scroller();
|
||||
|
||||
@@ -116,12 +116,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
return true;
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const rememberAnchor = useCallback(() => {
|
||||
const rememberAnchor = useCallback((capture = true) => {
|
||||
const el = ref.current;
|
||||
if (!el || el.clientHeight === 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
|
||||
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current);
|
||||
if (pinnedRef.current) {
|
||||
// Following the bottom needs only an offset, not a scan of every old row.
|
||||
anchorRef.current = null;
|
||||
} else if (capture) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
|
||||
}
|
||||
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current, pinnedRef.current);
|
||||
}, [navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
const reconcileContent = useCallback(() => {
|
||||
@@ -140,18 +145,20 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
|
||||
if (delta) el.scrollTop += delta;
|
||||
}
|
||||
rememberAnchor();
|
||||
// Compensation preserves the existing reading anchor. Recapture only when
|
||||
// the reader scrolls or navigation selects a different position.
|
||||
rememberAnchor(false);
|
||||
}, [rememberAnchor, scrollToBottom, hidePill]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!ref.current?.clientHeight) return;
|
||||
rememberAnchor();
|
||||
showScrollbarWhileScrolling();
|
||||
// A growing composer shrinks this viewport. Some browsers dispatch the
|
||||
// resulting scroll event before ResizeObserver, so preserve the previous
|
||||
// pinned state here instead of mistaking the layout change for a user
|
||||
// scroll away from the bottom.
|
||||
if (followViewportResize()) {
|
||||
rememberAnchor(false);
|
||||
hidePill();
|
||||
return;
|
||||
}
|
||||
@@ -164,9 +171,11 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
pinnedRef.current = true;
|
||||
hidePill();
|
||||
}
|
||||
rememberAnchor();
|
||||
return;
|
||||
}
|
||||
pinnedRef.current = pinned;
|
||||
rememberAnchor();
|
||||
if (pinned) hidePill();
|
||||
else showPill();
|
||||
}, [
|
||||
|
||||
@@ -16,7 +16,7 @@ export function taskChatScrollEntry(location: { key: string; hash: string; pathn
|
||||
|
||||
// Scoped to browser-history entries, not issues: opening the same task from a
|
||||
// new Inbox click starts at latest, while Back restores the previous reading.
|
||||
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null }>();
|
||||
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null; followBottom: boolean }>();
|
||||
|
||||
export function useTaskChatScrollNavigation() {
|
||||
const navigation = useContext(TaskChatScrollNavigation);
|
||||
@@ -32,6 +32,9 @@ export function useTaskChatScrollNavigation() {
|
||||
if (!navigation) return null;
|
||||
if (navigation.restore && positionKey && positions.has(positionKey)) {
|
||||
const saved = positions.get(positionKey)!;
|
||||
// A saved pixel offset goes stale when rotation changes text wrapping.
|
||||
// Bottom-follow needs no row measurements, even across navigation.
|
||||
if (saved.followBottom) return Math.max(0, root.scrollHeight - root.clientHeight);
|
||||
if (saved.anchor && [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].some((row) => row.dataset.threadAnchor === saved.anchor?.id)) {
|
||||
return scrollTop + threadScrollAnchorDelta(root, saved.anchor, viewportTop);
|
||||
}
|
||||
@@ -45,9 +48,9 @@ export function useTaskChatScrollNavigation() {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
remember(top: number, anchor: ThreadScrollAnchor | null) {
|
||||
remember(top: number, anchor: ThreadScrollAnchor | null, followBottom: boolean) {
|
||||
if (!positionKey || !ready) return;
|
||||
positions.set(positionKey, { top, anchor });
|
||||
positions.set(positionKey, { top, anchor, followBottom });
|
||||
if (positions.size > 100) positions.delete(positions.keys().next().value!);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWindowAutoFollow } from "./useWindowAutoFollow";
|
||||
import { TaskChatScrollNavigation } from "./scroll-navigation";
|
||||
|
||||
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
|
||||
useWindowAutoFollow(contentKey, enabled);
|
||||
return <div data-testid="task-chat-thread">thread</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom has no layout: document scroll geometry is faked on the scrolling
|
||||
* element, window.scrollTo is stubbed to record calls and update scrollY.
|
||||
*/
|
||||
describe("useWindowAutoFollow", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let scrollToCalls: number[];
|
||||
|
||||
function fakeWindowGeometry({ scrollHeight = 2000, innerHeight = 800 } = {}) {
|
||||
const el = document.scrollingElement ?? document.documentElement;
|
||||
let currentScrollHeight = scrollHeight;
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
get: () => currentScrollHeight,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true });
|
||||
return {
|
||||
setScrollHeight(value: number) {
|
||||
currentScrollHeight = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setWindowScrollY(y: number) {
|
||||
Object.defineProperty(window, "scrollY", { value: y, configurable: true });
|
||||
}
|
||||
|
||||
function render(contentKey: unknown, enabled = true) {
|
||||
flushSync(() => {
|
||||
root.render(<Host contentKey={contentKey} enabled={enabled} />);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire a window scroll event and flush the continuous-priority update. */
|
||||
function scrollWindowTo(y: number) {
|
||||
setWindowScrollY(y);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait out the mount effect's rAF re-follow so it can't leak into asserts. */
|
||||
function flushRaf() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => setTimeout(resolve));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
scrollToCalls = [];
|
||||
fakeWindowGeometry();
|
||||
setWindowScrollY(0);
|
||||
vi.stubGlobal("scrollTo", (options?: ScrollToOptions | number) => {
|
||||
const top = typeof options === "number" ? options : (options?.top ?? 0);
|
||||
scrollToCalls.push(top);
|
||||
// Mirror the browser: a follow to the bottom leaves the window pinned.
|
||||
setWindowScrollY(Math.max(0, top - window.innerHeight));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reapplies same-task targets and POP positions on mobile without remounting", async () => {
|
||||
vi.stubGlobal("scrollTo", (options: ScrollToOptions) => setWindowScrollY(Math.min(1200, options.top ?? 0)));
|
||||
function MobileThread() {
|
||||
useWindowAutoFollow("unchanged", true);
|
||||
return <div data-testid="task-chat-thread">{[100, 500].map((top, index) => (
|
||||
<div key={index} id={`mobile-comment-${index}`} data-thread-anchor={`mobile-comment-${index}`} ref={(node) => {
|
||||
if (node) node.getBoundingClientRect = () => ({ top: top - window.scrollY, bottom: top + 100 - window.scrollY, height: 100 } as DOMRect);
|
||||
}}>Comment {index}</div>
|
||||
))}</div>;
|
||||
}
|
||||
const navigate = (key: string, hash: string, restore = false) => flushSync(() => root.render(
|
||||
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}><MobileThread /></TaskChatScrollNavigation.Provider>,
|
||||
));
|
||||
navigate("mobile-entry-one", "#mobile-comment-0");
|
||||
const thread = container.firstElementChild;
|
||||
expect(window.scrollY).toBe(100);
|
||||
await scrollWindowTo(150);
|
||||
navigate("mobile-entry-two", "#mobile-comment-1");
|
||||
expect(container.firstElementChild).toBe(thread);
|
||||
expect(window.scrollY).toBe(500);
|
||||
navigate("mobile-entry-one", "#mobile-comment-0", true);
|
||||
expect(window.scrollY).toBe(150);
|
||||
navigate("mobile-entry-one", "#mobile-comment-1", true);
|
||||
expect(window.scrollY).toBe(500);
|
||||
});
|
||||
|
||||
it("owns browser restoration only while the mobile thread is enabled", () => {
|
||||
window.history.scrollRestoration = "auto";
|
||||
render(0);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
render(0, false);
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
|
||||
it("scrolls the window to the bottom on mount", () => {
|
||||
render(0);
|
||||
expect(scrollToCalls).toContain(2000);
|
||||
});
|
||||
|
||||
it("follows content growth while pinned to the bottom", async () => {
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(1200); // pinned: 2000 - 1200 - 800 = 0
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
expect(scrollToCalls).toContain(2000);
|
||||
});
|
||||
|
||||
it("holds position when the user has scrolled up", async () => {
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(100); // far from the bottom
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("follows document growth while pinned on mobile", async () => {
|
||||
let triggerResize = () => {};
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
triggerResize = () =>
|
||||
callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(1200);
|
||||
scrollToCalls = [];
|
||||
|
||||
geometry.setScrollHeight(2300);
|
||||
triggerResize();
|
||||
|
||||
expect(scrollToCalls).toContain(2300);
|
||||
});
|
||||
|
||||
it("holds document position through mobile composer growth when scrolled up", async () => {
|
||||
let triggerResize = () => {};
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
triggerResize = () =>
|
||||
callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
await flushRaf();
|
||||
await scrollWindowTo(100);
|
||||
scrollToCalls = [];
|
||||
|
||||
geometry.setScrollHeight(2300);
|
||||
triggerResize();
|
||||
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("follows a late thread resize without a React content update or body resize", async () => {
|
||||
const observed = new Set<Element>();
|
||||
let resizeThread = () => {};
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeThread = () => {
|
||||
const thread = container.querySelector('[data-testid="task-chat-thread"]')!;
|
||||
if (observed.has(thread)) callback([{ target: thread } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
};
|
||||
}
|
||||
observe(element: Element) { observed.add(element); }
|
||||
disconnect() { observed.clear(); }
|
||||
});
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
scrollToCalls = [];
|
||||
|
||||
geometry.setScrollHeight(2400);
|
||||
resizeThread();
|
||||
|
||||
expect(scrollToCalls).toContain(2400);
|
||||
expect(window.scrollY).toBe(1600);
|
||||
});
|
||||
|
||||
it("keeps following when a scroll event arrives after growth before reconciliation", async () => {
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
|
||||
geometry.setScrollHeight(2400);
|
||||
await scrollWindowTo(1200); // Still at the old bottom; the user did not move up.
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
|
||||
expect(scrollToCalls).toContain(2400);
|
||||
});
|
||||
|
||||
it("holds an upward user scroll during growth and resumes after returning to the bottom", async () => {
|
||||
const geometry = fakeWindowGeometry();
|
||||
render(0);
|
||||
|
||||
geometry.setScrollHeight(2400);
|
||||
await scrollWindowTo(900);
|
||||
scrollToCalls = [];
|
||||
render(1);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
|
||||
await scrollWindowTo(1600);
|
||||
geometry.setScrollHeight(2700);
|
||||
render(2);
|
||||
expect(scrollToCalls).toContain(2700);
|
||||
});
|
||||
|
||||
it("follows viewport resizing while pinned and holds it while reading older output", async () => {
|
||||
render(0);
|
||||
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(window.scrollY).toBe(1400);
|
||||
|
||||
await scrollWindowTo(500);
|
||||
scrollToCalls = [];
|
||||
Object.defineProperty(window, "innerHeight", { value: 700, configurable: true });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing when disabled", () => {
|
||||
render(0, false);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
render(1, false);
|
||||
expect(scrollToCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useTaskChatScrollNavigation } from "./scroll-navigation";
|
||||
|
||||
const PIN_THRESHOLD_PX = 48;
|
||||
|
||||
function scrollingElement(): Element | null {
|
||||
return document.scrollingElement ?? document.documentElement;
|
||||
}
|
||||
|
||||
function windowPinned(): boolean {
|
||||
const el = scrollingElement();
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - window.scrollY - window.innerHeight <= PIN_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
function scrollWindowToBottom(): void {
|
||||
const el = scrollingElement();
|
||||
if (!el) return;
|
||||
window.scrollTo({ top: el.scrollHeight, left: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Window-scroll counterpart of TaskMessageScroller's auto-follow rule, for the
|
||||
* mobile document-flow thread (the mobile app shell scrolls the document, not
|
||||
* an inner viewport — see Layout.tsx). While the window is pinned to the
|
||||
* bottom (within a small threshold) content growth follows with INSTANT
|
||||
* scroll; once the user scrolls up we hold their position.
|
||||
*
|
||||
* The conversation enables this hook after navigation has settled and before
|
||||
* its coordinated reveal, so initial positioning happens before paint.
|
||||
*/
|
||||
export function useWindowAutoFollow(contentKey: unknown, enabled: boolean): void {
|
||||
const pinnedRef = useRef(true);
|
||||
const lastScrollYRef = useRef(window.scrollY);
|
||||
const navigation = useTaskChatScrollNavigation();
|
||||
const initialPositionApplied = useRef(false);
|
||||
const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash });
|
||||
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
|
||||
const rememberAnchor = () => {
|
||||
lastScrollYRef.current = window.scrollY;
|
||||
const root = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
if (root) anchorRef.current = readThreadScrollAnchor(root, 0, window.innerHeight);
|
||||
if (initialPositionApplied.current) navigation.remember(window.scrollY, anchorRef.current);
|
||||
};
|
||||
const reconcile = () => {
|
||||
if (pinnedRef.current) scrollWindowToBottom();
|
||||
else {
|
||||
const root = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
if (root) {
|
||||
const delta = threadScrollAnchorDelta(root, anchorRef.current, 0);
|
||||
if (delta) window.scrollTo({ top: window.scrollY + delta, behavior: "auto" });
|
||||
}
|
||||
}
|
||||
rememberAnchor();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onScroll = () => {
|
||||
// Content or viewport growth can leave a gap below the old bottom before
|
||||
// ResizeObserver runs. Only an upward scroll gives up existing follow
|
||||
// intent; a scroll event at the same position must not strand the output.
|
||||
pinnedRef.current = windowPinned() || (pinnedRef.current && window.scrollY >= lastScrollYRef.current);
|
||||
rememberAnchor();
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", reconcile);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", reconcile);
|
||||
};
|
||||
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || typeof ResizeObserver === "undefined") return;
|
||||
let previousScrollHeight = scrollingElement()?.scrollHeight ?? null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextScrollHeight = scrollingElement()?.scrollHeight ?? null;
|
||||
if (
|
||||
nextScrollHeight == null ||
|
||||
nextScrollHeight === previousScrollHeight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
previousScrollHeight = nextScrollHeight;
|
||||
reconcile();
|
||||
});
|
||||
// The mobile body is viewport-sized even when its contents overflow. Watch
|
||||
// the thread box too, so streamed rows, images and expanding tools follow
|
||||
// after their own layout changes without needing a new parent content key.
|
||||
observer.observe(document.body);
|
||||
const thread = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
if (thread) observer.observe(thread);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
// Follow new content only when already pinned; otherwise hold position.
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) {
|
||||
appliedNavigation.current = { key: navigation.key, hash: navigation.hash };
|
||||
initialPositionApplied.current = false;
|
||||
}
|
||||
if (navigation.ready && !initialPositionApplied.current) {
|
||||
const root = document.querySelector('[data-testid="task-chat-thread"]');
|
||||
const top = root ? navigation.initialPosition(root, 0, window.scrollY) : null;
|
||||
if (top !== null) {
|
||||
window.scrollTo({ top, behavior: "auto" });
|
||||
pinnedRef.current = windowPinned();
|
||||
rememberAnchor();
|
||||
}
|
||||
initialPositionApplied.current = true;
|
||||
}
|
||||
reconcile();
|
||||
}, [contentKey, enabled, navigation.key, navigation.hash, navigation.ready]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
// One owner for document-flow compensation, as on the desktop viewport.
|
||||
const previousRestoration = window.history.scrollRestoration;
|
||||
window.history.scrollRestoration = "manual";
|
||||
document.documentElement.classList.add("task-chat-window-scroll");
|
||||
return () => {
|
||||
document.documentElement.classList.remove("task-chat-window-scroll");
|
||||
window.history.scrollRestoration = previousRestoration;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/** Mount below TaskChatScrollReady so mobile navigation also waits for targets
|
||||
* fetched after the conversation's first reveal. */
|
||||
export function TaskChatWindowScroll({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
|
||||
useWindowAutoFollow(contentKey, enabled);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { useMobileVisualViewport } from "./useMobileVisualViewport";
|
||||
|
||||
function Host({ mobile = true, conversation = true }: { mobile?: boolean; conversation?: boolean }) {
|
||||
const ref = useMobileVisualViewport(mobile);
|
||||
return <div ref={ref}><div data-task-chat-shell={conversation ? "" : undefined}>Chat shell</div></div>;
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let viewport: EventTarget & { height: number; offsetTop: number; scale: number };
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("innerHeight", 695);
|
||||
Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: 695 });
|
||||
viewport = Object.assign(new EventTarget(), { height: 695, offsetTop: 0, scale: 1 });
|
||||
vi.stubGlobal("visualViewport", viewport);
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
Reflect.deleteProperty(document.documentElement, "clientHeight");
|
||||
document.documentElement.scrollTop = 0;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resizes the shell from the visible height when iOS innerHeight shrinks too", () => {
|
||||
act(() => root.render(<Host />));
|
||||
const shell = container.firstElementChild as HTMLElement;
|
||||
act(() => {
|
||||
vi.stubGlobal("innerHeight", 358);
|
||||
viewport.height = 358;
|
||||
viewport.offsetTop = 337;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
|
||||
expect(shell.dataset.keyboardOpen).toBe("true");
|
||||
// Panning must not translate or resize the shell a second time.
|
||||
act(() => { viewport.offsetTop = 185; viewport.dispatchEvent(new Event("scroll")); });
|
||||
expect(shell.style.top).toBe("");
|
||||
expect(shell.style.transform).toBe("");
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
|
||||
act(() => { viewport.height = 695; viewport.offsetTop = 0; viewport.dispatchEvent(new Event("resize")); });
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
|
||||
expect(shell.dataset.keyboardOpen).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves pinch zoom and leaves desktop sizing to CSS", () => {
|
||||
act(() => root.render(<Host />));
|
||||
const shell = container.firstElementChild as HTMLElement;
|
||||
act(() => { viewport.scale = 2; viewport.height = 350; viewport.dispatchEvent(new Event("resize")); });
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
|
||||
act(() => root.render(<Host mobile={false} />));
|
||||
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("");
|
||||
expect(shell.dataset.keyboardOpen).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not invalidate shell styles for typing, scrolling, or unchanged viewport events", () => {
|
||||
act(() => root.render(<Host />));
|
||||
const shell = container.firstElementChild as HTMLElement;
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(shell, { attributes: true });
|
||||
act(() => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
shell.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
});
|
||||
expect(observer.takeRecords()).toHaveLength(0);
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
it("rejects Safari's stale document focus scroll after the keyboard has resized the conversation", () => {
|
||||
act(() => root.render(<Host />));
|
||||
act(() => {
|
||||
viewport.height = 358;
|
||||
// Some openings pan before the resize event; others pan one frame later.
|
||||
document.documentElement.scrollTop = 337;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(document.documentElement.scrollTop).toBe(0);
|
||||
document.documentElement.scrollTop = 337;
|
||||
document.dispatchEvent(new Event("scroll"));
|
||||
expect(document.documentElement.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves ordinary pages and inner message scrolling alone", () => {
|
||||
viewport.height = 358;
|
||||
act(() => root.render(<Host conversation={false} />));
|
||||
document.documentElement.scrollTop = 337;
|
||||
document.dispatchEvent(new Event("scroll"));
|
||||
expect(document.documentElement.scrollTop).toBe(337);
|
||||
act(() => root.render(<Host />));
|
||||
const messages = container.querySelector('[data-task-chat-shell]') as HTMLElement;
|
||||
messages.scrollTop = 250;
|
||||
messages.dispatchEvent(new Event("scroll"));
|
||||
expect(messages.scrollTop).toBe(250);
|
||||
});
|
||||
|
||||
it("does not suppress zoom panning when the keyboard was already open", () => {
|
||||
viewport.height = 358;
|
||||
act(() => root.render(<Host />));
|
||||
viewport.scale = 2;
|
||||
viewport.height = 179;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
document.documentElement.scrollTop = 120;
|
||||
document.dispatchEvent(new Event("scroll"));
|
||||
expect(document.documentElement.scrollTop).toBe(120);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
|
||||
/** Size the mobile conversation shell, never translate it with Safari's pan.
|
||||
* CSS owns the fixed shell and its flex layout; messages own their scrolling. */
|
||||
export function useMobileVisualViewport(enabled: boolean) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const shell = ref.current;
|
||||
const viewport = window.visualViewport;
|
||||
if (!enabled || !shell || !viewport) return;
|
||||
const restoreDocumentPosition = () => {
|
||||
if (viewport.scale !== 1 || shell.dataset.keyboardOpen !== "true" || !shell.querySelector("[data-task-chat-shell]")) return;
|
||||
const page = document.scrollingElement ?? document.documentElement;
|
||||
if (page.scrollTop !== 0) page.scrollTop = 0;
|
||||
};
|
||||
const update = () => {
|
||||
if (viewport.scale !== 1 || !Number.isFinite(viewport.height) || viewport.height <= 0) return;
|
||||
// Read geometry before writing styles, avoiding a forced layout after our
|
||||
// own height update. Nothing here subscribes to input or thread updates.
|
||||
const keyboardOpen = document.documentElement.clientHeight - viewport.height > 120;
|
||||
shell.style.setProperty("--mobile-chat-viewport-height", `${viewport.height}px`);
|
||||
// innerHeight can shrink with visualViewport on iOS, so it is not the
|
||||
// keyboard-closed baseline. The layout viewport remains stable.
|
||||
if (keyboardOpen) {
|
||||
shell.dataset.keyboardOpen = "true";
|
||||
} else {
|
||||
delete shell.dataset.keyboardOpen;
|
||||
}
|
||||
restoreDocumentPosition();
|
||||
};
|
||||
update();
|
||||
viewport.addEventListener("resize", update);
|
||||
window.addEventListener("resize", update);
|
||||
// Safari can apply its pre-keyboard focus pan AFTER the resize. The fixed
|
||||
// document is not a scroll surface. Listen without capture so scrolls in
|
||||
// the message list/editor never reach this fallback.
|
||||
document.addEventListener("scroll", restoreDocumentPosition);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", update);
|
||||
window.removeEventListener("resize", update);
|
||||
document.removeEventListener("scroll", restoreDocumentPosition);
|
||||
shell.style.removeProperty("--mobile-chat-viewport-height");
|
||||
delete shell.dataset.keyboardOpen;
|
||||
};
|
||||
}, [enabled]);
|
||||
return ref;
|
||||
}
|
||||
+58
-4
@@ -583,6 +583,8 @@
|
||||
leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap.
|
||||
Desktop and the classic thread use the safe-area default. */
|
||||
--tc-composer-bottom: var(--sz-calc-8);
|
||||
--mobile-chat-viewport-height: 100dvh;
|
||||
--tc-composer-editor-max-height: min(var(--sz-28dvh), calc(var(--mobile-chat-viewport-height) * 0.4));
|
||||
--tc-composer-visible-nav-offset: calc(4rem + var(--spacing) * 1.5 + var(--sz-safe-bottom));
|
||||
/* With the dock's pb-1, leave 12px below the card when the nav hides. */
|
||||
--tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2);
|
||||
@@ -887,6 +889,62 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
|
||||
transition: bottom var(--mobile-nav-motion-duration, var(--motion-mobile-nav-enter)) var(--mobile-nav-motion-ease, var(--motion-ease-out-expo));
|
||||
}
|
||||
|
||||
/* Mobile conversations use the same single message scroller + normal footer as
|
||||
desktop. Opt in from the rendered task shell, so classic tasks, lists, and
|
||||
the empty agent-chat picker retain their existing document layout. */
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body,
|
||||
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--mobile-chat-viewport-height);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) :is(.app-main-row, .app-content-row) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-content-column {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-breadcrumb {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) #main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--tc-composer-visible-nav-offset);
|
||||
}
|
||||
|
||||
.paperclip-app-shell[data-mobile="true"][data-keyboard-open="true"]:has([data-task-chat-shell]) #main-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-mobile-nav-enter: 0ms;
|
||||
@@ -3292,10 +3350,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.task-chat-window-scroll {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.task-chat-loading-shell .animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -7882,11 +7882,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? isMobile
|
||||
? // Mobile shell scrolls the DOCUMENT (main is overflow-visible,
|
||||
// auto height) — the thread renders in normal flow (PAP-360).
|
||||
"flex w-full flex-col gap-6"
|
||||
: // Fill main exactly so the outer page never scrolls — the
|
||||
? // Fill main exactly so the outer page never scrolls — the
|
||||
// thread's own viewport is the only scroll surface.
|
||||
// Keep status banners close to the transcript. A full section
|
||||
// gap here shortens the pinned message viewport enough to
|
||||
@@ -8180,9 +8176,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onValueChange={setDetailTab}
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? isMobile
|
||||
? undefined
|
||||
: "min-h-0 flex-1"
|
||||
? "min-h-0 flex-1"
|
||||
: "space-y-3"
|
||||
}
|
||||
>
|
||||
@@ -8222,8 +8216,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
taskChatShellEnabled
|
||||
? isMobile
|
||||
? streamlinedTaskDetailEnabled
|
||||
? undefined
|
||||
: "-mx-4"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "-mx-4 flex min-h-0 flex-1 flex-col"
|
||||
: streamlinedTaskDetailEnabled
|
||||
? "flex min-h-0 flex-col"
|
||||
: "-mx-4 -mt-4 md:-mx-6 md:-mt-6 flex min-h-0 flex-col"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MobileBottomNav } from "@/components/MobileBottomNav";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
|
||||
import { useMobileVisualViewport } from "@/hooks/useMobileVisualViewport";
|
||||
|
||||
function StreamingResponse({ running }: { running: boolean }) {
|
||||
const [lines, setLines] = useState(16);
|
||||
@@ -13,7 +13,7 @@ function StreamingResponse({ running }: { running: boolean }) {
|
||||
const timer = window.setTimeout(() => setLines(lines + 1), 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [running, lines]);
|
||||
return <TaskChatThreadView scroll={false} items={[
|
||||
return <TaskChatThreadView items={[
|
||||
{ id: "request", kind: "message", author: "human", text: "Give me a detailed progress report as you work." },
|
||||
{
|
||||
id: "response", kind: "message", author: "agent", authorName: "Researcher", streaming: running,
|
||||
@@ -24,21 +24,19 @@ function StreamingResponse({ running }: { running: boolean }) {
|
||||
|
||||
function MobileFollow() {
|
||||
const [running, setRunning] = useState(true);
|
||||
// The child updates independently, exercising late layout growth as well as
|
||||
// normal streaming without relying on a parent content-key notification.
|
||||
useWindowAutoFollow(0, true);
|
||||
return <div className="flex min-h-dvh flex-col bg-background text-foreground">
|
||||
<header className="flex h-12 shrink-0 items-center px-4 text-sm font-medium">Streaming progress report</header>
|
||||
<main className="flex flex-1 flex-col p-4 pb-(--tc-composer-visible-nav-offset)"
|
||||
style={{ "--tc-composer-bottom": "var(--tc-composer-visible-nav-offset)" } as CSSProperties}>
|
||||
<div data-testid="task-chat-thread" className="-mx-4 flex flex-col">
|
||||
const viewportRef = useMobileVisualViewport(true);
|
||||
return <div ref={viewportRef} data-mobile="true" className="paperclip-app-shell flex min-h-dvh flex-col bg-background text-foreground">
|
||||
<div className="app-main-row"><div className="app-content-column flex flex-col">
|
||||
<header className="app-breadcrumb flex h-12 shrink-0 items-center px-4 text-sm font-medium">iOS layout — focus-scroll correction</header>
|
||||
<div className="app-content-row"><main id="main-content" className="flex flex-1 flex-col p-4">
|
||||
<div data-task-chat-shell="" data-testid="task-chat-thread" className="-mx-4 flex min-h-0 flex-1 flex-col">
|
||||
<StreamingResponse running={running} />
|
||||
<TaskChatComposerDock mobile streamlined={false}>
|
||||
<TaskChatComposer mobile workMode="standard" onAdd={async () => setRunning(true)}
|
||||
onStop={running ? async () => setRunning(false) : undefined} />
|
||||
</TaskChatComposerDock>
|
||||
</div>
|
||||
</main>
|
||||
</main></div></div></div>
|
||||
<MobileBottomNav visible />
|
||||
</div>;
|
||||
}
|
||||
@@ -56,7 +54,7 @@ const meta = {
|
||||
],
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
"notes": "The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
|
||||
"notes": "Production mobile viewport sizing, message scroller, and composer with bounded synthetic streaming. Physical iPhone keyboard acceptance is required."
|
||||
},
|
||||
},
|
||||
globals: { theme: "light", viewport: { value: "mobile", isRotated: false } },
|
||||
|
||||
Reference in new issue
Block a user