mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
5 Commits
76e336a1be
...
bbf3d0f9cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf3d0f9cc | ||
|
|
963d4f9240 | ||
|
|
e241e02afb | ||
|
|
fdee3236f7 | ||
|
|
fd7061a8b2 |
114
.github/workflows/ci.yml
vendored
114
.github/workflows/ci.yml
vendored
@@ -115,6 +115,9 @@ jobs:
|
||||
- 'packages/relay/**'
|
||||
- 'packages/server/**'
|
||||
|
||||
- name: Validate CI workflow
|
||||
run: node --test scripts/ci-workflow.test.mjs
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -138,7 +141,7 @@ jobs:
|
||||
lint:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
@@ -168,7 +171,7 @@ jobs:
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.quality != 'false') }}
|
||||
@@ -199,11 +202,7 @@ jobs:
|
||||
|
||||
server-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.server != 'false') }}
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -212,28 +211,43 @@ jobs:
|
||||
name: server-tests (${{ matrix.os }})
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.server != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected server tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No server changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build server dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -242,11 +256,7 @@ jobs:
|
||||
|
||||
desktop-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop != 'false') }}
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -255,39 +265,53 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected desktop tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No desktop changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Build app dependencies for desktop E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Install virtual display
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||
|
||||
- name: Run real Electron browser tab bridge E2E
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
|
||||
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
|
||||
env:
|
||||
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
|
||||
- name: Upload browser tab bridge diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure() && matrix.os == 'ubuntu-latest'
|
||||
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: browser-tab-bridge-e2e
|
||||
path: ${{ runner.temp }}/browser-tab-bridge-e2e
|
||||
@@ -296,7 +320,7 @@ jobs:
|
||||
|
||||
- name: Build and smoke unpacked desktop app
|
||||
if: >-
|
||||
matrix.os == 'ubuntu-latest' &&
|
||||
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
@@ -308,7 +332,7 @@ jobs:
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
if: >-
|
||||
failure() && matrix.os == 'ubuntu-latest' &&
|
||||
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.desktop_package != 'false')
|
||||
@@ -322,7 +346,7 @@ jobs:
|
||||
app-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.app != 'false') }}
|
||||
@@ -352,7 +376,7 @@ jobs:
|
||||
sdk-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.sdk != 'false') }}
|
||||
@@ -383,11 +407,7 @@ jobs:
|
||||
|
||||
playwright:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.playwright != 'false') }}
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -401,43 +421,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.playwright != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected Playwright tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No Playwright changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
- name: Install Playwright browsers
|
||||
if: env.RUN_TESTS == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: ${{ !matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && !matrix.desktop
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Run desktop-overlay Playwright tests
|
||||
if: ${{ matrix.desktop }}
|
||||
if: env.RUN_TESTS == 'true' && matrix.desktop
|
||||
run: npm run test:e2e:desktop --workspace=@getpaseo/app
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: env.RUN_TESTS == 'true' && failure()
|
||||
with:
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
@@ -448,7 +482,7 @@ jobs:
|
||||
relay-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
${{ !cancelled() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.relay != 'false') }}
|
||||
@@ -474,11 +508,7 @@ jobs:
|
||||
|
||||
cli-tests:
|
||||
needs: changes
|
||||
if: >-
|
||||
${{ always() &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.cli != 'false') }}
|
||||
if: ${{ !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -487,24 +517,38 @@ jobs:
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
RUN_TESTS: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
needs.changes.result != 'success' ||
|
||||
needs.changes.outputs.cli != 'false' }}
|
||||
steps:
|
||||
- name: Skip unaffected CLI tests
|
||||
if: env.RUN_TESTS != 'true'
|
||||
run: echo "No CLI changes detected."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: env.RUN_TESTS == 'true'
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
if: env.RUN_TESTS == 'true'
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
type ScrollKeyboardDismissEvent,
|
||||
type ScrollKeyboardDismissGesture,
|
||||
} from "./model";
|
||||
|
||||
interface Point {
|
||||
ts: number;
|
||||
y: number;
|
||||
nativeTs?: number | null;
|
||||
}
|
||||
|
||||
function event(point: Point): ScrollKeyboardDismissEvent {
|
||||
return {
|
||||
timeStamp: point.ts,
|
||||
nativeEvent: {
|
||||
contentOffset: { y: point.y },
|
||||
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
|
||||
return points.reduce(
|
||||
(gesture, point) => recordScroll(gesture, event(point)),
|
||||
beginDrag(event(start)),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
|
||||
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
|
||||
}
|
||||
|
||||
describe("scroll keyboard dismissal", () => {
|
||||
it("keeps the keyboard up for a slow read-scroll", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 8 },
|
||||
{ ts: 1080, y: 16 },
|
||||
{ ts: 1120, y: 24 },
|
||||
],
|
||||
{ ts: 1160, y: 32 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses for an upward flick", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a fast-but-decelerating drag below the release threshold", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1100, y: 500 },
|
||||
{ ts: 1200, y: 590 },
|
||||
{ ts: 1270, y: 598 },
|
||||
],
|
||||
{ ts: 1300, y: 600 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the whole drag when the gesture is too short to sample", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
|
||||
});
|
||||
|
||||
it("steps back a sample when release lands immediately after one", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 1040, y: 120 },
|
||||
{ ts: 1075, y: 225 },
|
||||
],
|
||||
{ ts: 1080, y: 240 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the keyboard up when release has no measurable time span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores scroll events that arrive before the minimum sample span", () => {
|
||||
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
|
||||
expect(
|
||||
shouldDismiss(
|
||||
{ ts: 5000, nativeTs: 1000, y: 0 },
|
||||
[
|
||||
{ ts: 5001, nativeTs: 1200, y: 40 },
|
||||
{ ts: 5002, nativeTs: 1400, y: 80 },
|
||||
],
|
||||
{ ts: 5003, nativeTs: 1600, y: 120 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to synthetic event time when native time is absent", () => {
|
||||
expect(
|
||||
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
|
||||
ts: 1080,
|
||||
nativeTs: null,
|
||||
y: 240,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores scroll and release events without a matching drag", () => {
|
||||
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
|
||||
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to idle after release", () => {
|
||||
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
|
||||
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
});
|
||||
});
|
||||
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pure state machine for the chat history's flick-to-dismiss gesture.
|
||||
*
|
||||
* Native keyboardDismissMode cannot express this interaction: "on-drag"
|
||||
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
|
||||
* inverted list. We therefore classify the list's existing scroll gesture at
|
||||
* release instead of introducing a competing pan recognizer.
|
||||
*/
|
||||
|
||||
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
|
||||
const RELEASE_SAMPLE_MIN_MS = 30;
|
||||
|
||||
/**
|
||||
* The subset of a native scroll event used by the classifier. React Native's
|
||||
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
|
||||
*/
|
||||
export interface ScrollKeyboardDismissEvent {
|
||||
timeStamp: number;
|
||||
nativeEvent: {
|
||||
contentOffset: { y: number };
|
||||
timestamp?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DragSamples {
|
||||
startTs: number;
|
||||
startY: number;
|
||||
sampleTs: number;
|
||||
sampleY: number;
|
||||
previousSampleTs: number;
|
||||
previousSampleY: number;
|
||||
}
|
||||
|
||||
export type ScrollKeyboardDismissGesture =
|
||||
| { phase: "idle" }
|
||||
| { phase: "dragging"; samples: DragSamples };
|
||||
|
||||
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
|
||||
phase: "idle",
|
||||
});
|
||||
|
||||
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
|
||||
const nativeTimestamp = event.nativeEvent.timestamp;
|
||||
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
|
||||
return nativeTimestamp;
|
||||
}
|
||||
return event.timeStamp;
|
||||
}
|
||||
|
||||
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: timestamp,
|
||||
startY: offsetY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: offsetY,
|
||||
previousSampleTs: 0,
|
||||
previousSampleY: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordScroll(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): ScrollKeyboardDismissGesture {
|
||||
if (gesture.phase === "idle") {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const timestamp = resolveEventTimeMs(event);
|
||||
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
|
||||
return gesture;
|
||||
}
|
||||
|
||||
const { samples } = gesture;
|
||||
return {
|
||||
phase: "dragging",
|
||||
samples: {
|
||||
startTs: samples.startTs,
|
||||
startY: samples.startY,
|
||||
sampleTs: timestamp,
|
||||
sampleY: event.nativeEvent.contentOffset.y,
|
||||
previousSampleTs: samples.sampleTs,
|
||||
previousSampleY: samples.sampleY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseDrag(
|
||||
gesture: ScrollKeyboardDismissGesture,
|
||||
event: ScrollKeyboardDismissEvent,
|
||||
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
|
||||
if (gesture.phase === "idle") {
|
||||
return { gesture, shouldDismiss: false };
|
||||
}
|
||||
|
||||
const releaseTs = resolveEventTimeMs(event);
|
||||
const releaseY = event.nativeEvent.contentOffset.y;
|
||||
const { samples } = gesture;
|
||||
|
||||
// The release event carries the gesture's true endpoint. The final onScroll
|
||||
// event can still be stale when a short flick lands.
|
||||
let spanStartTs = samples.startTs;
|
||||
let spanStartY = samples.startY;
|
||||
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
|
||||
spanStartTs = samples.sampleTs;
|
||||
spanStartY = samples.sampleY;
|
||||
} else if (samples.previousSampleTs > 0) {
|
||||
spanStartTs = samples.previousSampleTs;
|
||||
spanStartY = samples.previousSampleY;
|
||||
}
|
||||
|
||||
const spanDurationMs = releaseTs - spanStartTs;
|
||||
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
|
||||
|
||||
return {
|
||||
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
TextInput,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
beginDrag,
|
||||
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
|
||||
recordScroll,
|
||||
releaseDrag,
|
||||
} from "./model";
|
||||
|
||||
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
|
||||
|
||||
/**
|
||||
* Owns the chat history's flick-to-dismiss behavior. The native stream only
|
||||
* forwards the FlatList scroll lifecycle; removing this hook and those three
|
||||
* calls removes the feature completely.
|
||||
*/
|
||||
export function useScrollKeyboardDismiss() {
|
||||
const { shift } = useKeyboardShift();
|
||||
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
|
||||
|
||||
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = beginDrag(event);
|
||||
});
|
||||
|
||||
const onScroll = useStableEvent((event: ScrollEvent) => {
|
||||
gestureRef.current = recordScroll(gestureRef.current, event);
|
||||
});
|
||||
|
||||
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
|
||||
const release = releaseDrag(gestureRef.current, event);
|
||||
gestureRef.current = release.gesture;
|
||||
|
||||
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
|
||||
// second calculation on JS, this prevents a hardware keyboard's focused
|
||||
// composer from being blurred when no software keyboard occupies space.
|
||||
if (!release.shouldDismiss || shift.value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep blur and dismiss paired: this exact sequence was validated on a
|
||||
// physical Android device to clear both input focus and the IME inset.
|
||||
const focusedInput = TextInput.State.currentlyFocusedInput();
|
||||
if (focusedInput) {
|
||||
TextInput.State.blurTextInput(focusedInput);
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
});
|
||||
|
||||
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import {
|
||||
createStreamStrategy,
|
||||
@@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
@@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
@@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
scrollKeyboardDismiss.onScroll(event);
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||
@@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent(() => {
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
@@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
@@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// gesture position now because layout may move the viewport in the meantime.
|
||||
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const isNearBottom = isScrollEventNearBottom(event);
|
||||
scrollKeyboardDismiss.onScrollEndDrag(event);
|
||||
|
||||
clearPendingUserScrollEnd();
|
||||
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
||||
userScrollEndFrameIdRef.current = null;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
|
||||
18
packages/app/src/hooks/keyboard-shift-context.ts
Normal file
18
packages/app/src/hooks/keyboard-shift-context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
|
||||
export interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
|
||||
/** Read the app-wide keyboard inset without loading its native provider implementation. */
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createElement, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -23,16 +16,10 @@ import {
|
||||
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||
resolveKeyboardShift,
|
||||
} from "@/hooks/keyboard-shift-policy";
|
||||
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
type KeyboardShiftMode = "translate" | "padding";
|
||||
|
||||
interface KeyboardShiftContextValue {
|
||||
shift: SharedValue<number>;
|
||||
bottomInset: SharedValue<number>;
|
||||
}
|
||||
|
||||
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
|
||||
|
||||
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
|
||||
@@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
|
||||
return createElement(KeyboardShiftContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useKeyboardShift(): KeyboardShiftContextValue {
|
||||
const context = useContext(KeyboardShiftContext);
|
||||
if (!context) {
|
||||
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
|
||||
shift: SharedValue<number>;
|
||||
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
|
||||
|
||||
@@ -35,6 +35,19 @@ describe("canonical CLI surface", () => {
|
||||
expect(run?.helpInformation()).not.toContain("--detach");
|
||||
});
|
||||
|
||||
it("offers thinking configuration when running, updating, and scheduling agents", () => {
|
||||
const cli = createCli();
|
||||
const run = cli.commands.find((command) => command.name() === "run");
|
||||
const agent = cli.commands.find((command) => command.name() === "agent");
|
||||
const update = agent?.commands.find((command) => command.name() === "update");
|
||||
const schedule = cli.commands.find((command) => command.name() === "schedule");
|
||||
const scheduleCreate = schedule?.commands.find((command) => command.name() === "create");
|
||||
|
||||
expect(run?.helpInformation()).toContain("--thinking <id>");
|
||||
expect(update?.helpInformation()).toContain("--thinking <id>");
|
||||
expect(scheduleCreate?.helpInformation()).toContain("--thinking <id>");
|
||||
});
|
||||
|
||||
it("offers opening an existing agent in the desktop app", () => {
|
||||
const agent = createCli().commands.find((command) => command.name() === "agent");
|
||||
const open = agent?.commands.find((command) => command.name() === "open");
|
||||
|
||||
@@ -92,9 +92,10 @@ export function createAgentCommand(): Command {
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("update")
|
||||
.description("Update an agent's metadata")
|
||||
.description("Update an agent's settings or metadata")
|
||||
.argument("<id>", "Agent ID (or prefix)")
|
||||
.option("--name <name>", "Update the agent's display name")
|
||||
.option("--thinking <id>", "Update the agent's thinking option ID")
|
||||
.option(
|
||||
"--label <label>",
|
||||
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",
|
||||
|
||||
108
packages/cli/src/commands/agent/update.test.ts
Normal file
108
packages/cli/src/commands/agent/update.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
applyAgentChanges,
|
||||
toAgentUpdateResult,
|
||||
type AgentMetadataChanges,
|
||||
type AgentUpdateClient,
|
||||
} from "./update.js";
|
||||
|
||||
class RecordingAgentUpdateClient implements AgentUpdateClient {
|
||||
readonly metadataUpdates: Array<{
|
||||
agentId: string;
|
||||
updates: AgentMetadataChanges;
|
||||
}> = [];
|
||||
readonly thinkingUpdates: Array<{ agentId: string; thinkingOptionId: string }> = [];
|
||||
thinkingNotice: AgentProviderNotice | null = null;
|
||||
|
||||
constructor(private readonly supportsThinkingUpdate = true) {}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return { features: { agentThinkingUpdate: this.supportsThinkingUpdate } };
|
||||
}
|
||||
|
||||
async updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void> {
|
||||
this.metadataUpdates.push({ agentId, updates });
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
this.thinkingUpdates.push({ agentId, thinkingOptionId });
|
||||
return this.thinkingNotice;
|
||||
}
|
||||
}
|
||||
|
||||
describe("applyAgentChanges", () => {
|
||||
it("updates an agent's thinking without issuing an empty metadata update", async () => {
|
||||
const client = new RecordingAgentUpdateClient();
|
||||
|
||||
const result = await applyAgentChanges(client, "agent-1", {
|
||||
type: "thinking",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
expect(client.metadataUpdates).toEqual([]);
|
||||
expect(client.thinkingUpdates).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
|
||||
expect(result).toEqual({
|
||||
notice: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a daemon that advertises thinking updates", async () => {
|
||||
const client = new RecordingAgentUpdateClient(false);
|
||||
|
||||
await expect(
|
||||
applyAgentChanges(client, "agent-1", { type: "thinking", thinkingOptionId: "high" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to use agent thinking updates.",
|
||||
});
|
||||
expect(client.metadataUpdates).toEqual([]);
|
||||
expect(client.thinkingUpdates).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the provider notice from a thinking update", async () => {
|
||||
const client = new RecordingAgentUpdateClient();
|
||||
client.thinkingNotice = {
|
||||
type: "warning",
|
||||
message: "Thinking changes apply to the next turn.",
|
||||
};
|
||||
|
||||
const notice = await applyAgentChanges(client, "agent-1", {
|
||||
type: "thinking",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
expect(notice).toEqual({
|
||||
notice: {
|
||||
type: "warning",
|
||||
message: "Thinking changes apply to the next turn.",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAgentUpdateResult", () => {
|
||||
it("reports the current thinking option after a metadata update", () => {
|
||||
const result = toAgentUpdateResult(
|
||||
{
|
||||
id: "agent-1",
|
||||
title: "Renamed agent",
|
||||
labels: { team: "platform" },
|
||||
effectiveThinkingOptionId: "high",
|
||||
},
|
||||
{ notice: null },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
agentId: "agent-1",
|
||||
name: "Renamed agent",
|
||||
labels: "team=platform",
|
||||
thinkingOptionId: "high",
|
||||
noticeType: null,
|
||||
notice: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -12,6 +14,9 @@ export interface AgentUpdateResult {
|
||||
agentId: string;
|
||||
name: string | null;
|
||||
labels: string;
|
||||
thinkingOptionId: string | null;
|
||||
noticeType: AgentProviderNotice["type"] | null;
|
||||
notice: string | null;
|
||||
}
|
||||
|
||||
/** Schema for update command output */
|
||||
@@ -21,17 +26,80 @@ export const updateSchema: OutputSchema<AgentUpdateResult> = {
|
||||
{ header: "AGENT ID", field: "agentId" },
|
||||
{ header: "NAME", field: "name" },
|
||||
{ header: "LABELS", field: "labels" },
|
||||
{ header: "THINKING", field: "thinkingOptionId" },
|
||||
{ header: "NOTICE", field: "notice" },
|
||||
],
|
||||
};
|
||||
|
||||
export interface AgentUpdateOptions extends CommandOptions {
|
||||
name?: string;
|
||||
label?: string[];
|
||||
thinking?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
|
||||
|
||||
export interface AgentMetadataChanges {
|
||||
name?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AgentUpdateServerInfo {
|
||||
features?: { agentThinkingUpdate?: boolean };
|
||||
}
|
||||
|
||||
export interface AgentUpdateClient {
|
||||
getLastServerInfoMessage(): AgentUpdateServerInfo | null;
|
||||
updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void>;
|
||||
setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string,
|
||||
): Promise<AgentProviderNotice | null>;
|
||||
}
|
||||
|
||||
export type AgentChanges =
|
||||
| { type: "metadata"; updates: AgentMetadataChanges }
|
||||
| { type: "thinking"; thinkingOptionId: string };
|
||||
|
||||
export interface AppliedAgentChanges {
|
||||
notice: AgentProviderNotice | null;
|
||||
}
|
||||
|
||||
export function toAgentUpdateResult(
|
||||
agent: Pick<AgentSnapshotPayload, "id" | "title" | "labels" | "effectiveThinkingOptionId">,
|
||||
appliedChanges: AppliedAgentChanges,
|
||||
): AgentUpdateResult {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name: agent.title,
|
||||
labels: formatLabels(agent.labels),
|
||||
thinkingOptionId: agent.effectiveThinkingOptionId ?? null,
|
||||
noticeType: appliedChanges.notice?.type ?? null,
|
||||
notice: appliedChanges.notice?.message ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyAgentChanges(
|
||||
client: AgentUpdateClient,
|
||||
agentId: string,
|
||||
changes: AgentChanges,
|
||||
): Promise<AppliedAgentChanges> {
|
||||
if (changes.type === "thinking") {
|
||||
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
|
||||
if (client.getLastServerInfoMessage()?.features?.agentThinkingUpdate !== true) {
|
||||
throw {
|
||||
code: "DAEMON_UPDATE_REQUIRED",
|
||||
message: "Update the host to use agent thinking updates.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const notice = await client.setAgentThinkingOption(agentId, changes.thinkingOptionId);
|
||||
return { notice };
|
||||
}
|
||||
await client.updateAgent(agentId, changes.updates);
|
||||
return { notice: null };
|
||||
}
|
||||
|
||||
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
if (!labels) {
|
||||
@@ -81,6 +149,55 @@ function formatLabels(labels: Record<string, string>): string {
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join(",");
|
||||
}
|
||||
|
||||
function parseAgentChanges(options: AgentUpdateOptions): AgentChanges {
|
||||
const name = options.name?.trim();
|
||||
if (options.name !== undefined && !name) {
|
||||
throw {
|
||||
code: "INVALID_NAME",
|
||||
message: "Name cannot be empty",
|
||||
details: "Use --name <name> with a non-empty value",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const labels = parseLabelOptions(options.label);
|
||||
const thinkingOptionId = options.thinking?.trim();
|
||||
if (options.thinking !== undefined && !thinkingOptionId) {
|
||||
throw {
|
||||
code: "INVALID_THINKING_OPTION",
|
||||
message: "--thinking cannot be empty",
|
||||
details:
|
||||
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const hasMetadataUpdates = Boolean(name) || Object.keys(labels).length > 0;
|
||||
if (hasMetadataUpdates && thinkingOptionId) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--thinking cannot be combined with --name or --label",
|
||||
details: "Run separate agent update commands for runtime settings and metadata.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (!hasMetadataUpdates && !thinkingOptionId) {
|
||||
throw {
|
||||
code: "NO_CHANGES_PROVIDED",
|
||||
message: "Nothing to update",
|
||||
details: "Provide at least one of: --name <name>, --label <key=value>, --thinking <id>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (thinkingOptionId) {
|
||||
return { type: "thinking", thinkingOptionId };
|
||||
}
|
||||
return {
|
||||
type: "metadata",
|
||||
updates: {
|
||||
...(name ? { name } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runUpdateCommand(
|
||||
agentIdArg: string,
|
||||
options: AgentUpdateOptions,
|
||||
@@ -98,25 +215,7 @@ export async function runUpdateCommand(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const name = options.name?.trim();
|
||||
if (options.name !== undefined && !name) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_NAME",
|
||||
message: "Name cannot be empty",
|
||||
details: "Use --name <name> with a non-empty value",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const labels = parseLabelOptions(options.label);
|
||||
if (!name && Object.keys(labels).length === 0) {
|
||||
const error: CommandError = {
|
||||
code: "NO_CHANGES_PROVIDED",
|
||||
message: "Nothing to update",
|
||||
details: "Provide at least one of: --name <name>, --label <key=value>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const changes = parseAgentChanges(options);
|
||||
|
||||
let client;
|
||||
try {
|
||||
@@ -143,10 +242,7 @@ export async function runUpdateCommand(
|
||||
}
|
||||
const agentId = fetchResult.agent.id;
|
||||
|
||||
await client.updateAgent(agentId, {
|
||||
...(name ? { name } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
});
|
||||
const appliedChanges = await applyAgentChanges(client, agentId, changes);
|
||||
|
||||
const updatedResult = await client.fetchAgent({ agentId });
|
||||
if (!updatedResult) {
|
||||
@@ -157,11 +253,7 @@ export async function runUpdateCommand(
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
agentId,
|
||||
name: updatedResult.agent.title,
|
||||
labels: formatLabels(updatedResult.agent.labels),
|
||||
},
|
||||
data: toAgentUpdateResult(updatedResult.agent, appliedChanges),
|
||||
schema: updateSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
target?: string;
|
||||
provider?: string;
|
||||
mode?: string;
|
||||
thinking?: string;
|
||||
cwd?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
@@ -40,6 +41,7 @@ export async function runCreateCommand(
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
mode: options.mode,
|
||||
thinking: options.thinking,
|
||||
cwd: options.cwd,
|
||||
host: options.host,
|
||||
maxRuns: options.maxRuns,
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createScheduleCommand(): Command {
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--thinking <id>", "Thinking option ID for new-agent runs")
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation")
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
|
||||
@@ -116,6 +116,25 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleCreateInput thinking", () => {
|
||||
test("sets the thinking option for each scheduled new-agent run", () => {
|
||||
const input = parseScheduleCreateInput({
|
||||
...baseOptions,
|
||||
cwd: "/project",
|
||||
thinking: " high ",
|
||||
});
|
||||
|
||||
expect(input.target).toEqual({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: "/project",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleUpdateInput", () => {
|
||||
test("rejects calls with no fields to update", () => {
|
||||
expect(() => parseScheduleUpdateInput({ id: "abc" })).toThrow(
|
||||
|
||||
@@ -114,7 +114,7 @@ function resolveScheduleTarget(args: {
|
||||
if (hasExplicitNewAgentOption) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider/--mode can only be used with a new-agent target",
|
||||
message: "--provider/--mode/--thinking can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
@@ -144,6 +144,7 @@ export function parseScheduleCreateInput(options: {
|
||||
target?: string;
|
||||
provider?: string;
|
||||
mode?: string;
|
||||
thinking?: string;
|
||||
cwd?: string;
|
||||
host?: string;
|
||||
maxRuns?: string;
|
||||
@@ -179,7 +180,15 @@ export function parseScheduleCreateInput(options: {
|
||||
|
||||
const targetValue = options.target?.trim();
|
||||
const modeId = options.mode?.trim();
|
||||
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
|
||||
const thinkingOptionId = options.thinking?.trim();
|
||||
if (options.thinking !== undefined && !thinkingOptionId) {
|
||||
throw {
|
||||
code: "INVALID_THINKING_OPTION",
|
||||
message: "--thinking cannot be empty",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const hasExplicitNewAgentOption =
|
||||
options.provider !== undefined || options.mode !== undefined || options.thinking !== undefined;
|
||||
const createNewAgentTarget = (): ScheduleTarget => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: options.provider,
|
||||
@@ -191,6 +200,7 @@ export function parseScheduleCreateInput(options: {
|
||||
cwd: cwdInput ?? process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ try {
|
||||
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
|
||||
assert(result.stdout.includes("--name"), "help should mention --name flag");
|
||||
assert(result.stdout.includes("--label"), "help should mention --label flag");
|
||||
assert(result.stdout.includes("--thinking"), "help should mention --thinking flag");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("<id>"), "help should mention required id argument");
|
||||
console.log("✓ agent update --help shows options\n");
|
||||
@@ -103,6 +104,30 @@ try {
|
||||
assert(result.stdout.includes("update"), "help should mention update subcommand");
|
||||
console.log("✓ agent --help shows update subcommand\n");
|
||||
}
|
||||
|
||||
// Test 7: agent update accepts thinking as an update field
|
||||
{
|
||||
console.log("Test 7: agent update accepts thinking as an update field");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow();
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(!output.includes("Nothing to update"), "should treat --thinking as an update field");
|
||||
console.log("✓ agent update accepts thinking as an update field\n");
|
||||
}
|
||||
|
||||
// Test 8: thinking cannot be combined with metadata updates
|
||||
{
|
||||
console.log("Test 8: thinking cannot be combined with metadata updates");
|
||||
const result =
|
||||
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should reject a combined update");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--thinking cannot be combined with --name or --label"),
|
||||
"should explain that thinking and metadata updates are separate operations",
|
||||
);
|
||||
console.log("✓ thinking cannot be combined with metadata updates\n");
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
|
||||
@@ -100,6 +100,8 @@ try {
|
||||
"10m",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
@@ -113,6 +115,7 @@ try {
|
||||
const inspectedJson = JSON.parse(inspected.stdout);
|
||||
assert.strictEqual(inspectedJson.target.config.provider, "codex");
|
||||
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
|
||||
assert.strictEqual(inspectedJson.target.config.thinkingOptionId, "high");
|
||||
|
||||
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
|
||||
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js";
|
||||
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
app,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { configureLinuxSandbox } from "./system/linux-sandbox.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
registerNotificationHandlers,
|
||||
@@ -309,12 +310,13 @@ if (forcedUserDataDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID
|
||||
// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the
|
||||
// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code.
|
||||
if (process.platform === "linux" && process.env.APPIMAGE) {
|
||||
app.commandLine.appendSwitch("no-sandbox");
|
||||
}
|
||||
configureLinuxSandbox({
|
||||
platform: process.platform,
|
||||
resourcesPath: process.resourcesPath,
|
||||
statSandbox: (sandboxPath) => statSync(sandboxPath),
|
||||
disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"),
|
||||
reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error),
|
||||
});
|
||||
|
||||
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
|
||||
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
|
||||
|
||||
82
packages/desktop/src/system/linux-sandbox.test.ts
Normal file
82
packages/desktop/src/system/linux-sandbox.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configureLinuxSandbox } from "./linux-sandbox";
|
||||
|
||||
interface SandboxMetadata {
|
||||
mode: number;
|
||||
uid: number;
|
||||
}
|
||||
|
||||
function configureWithSandbox(
|
||||
sandbox: SandboxMetadata | Error,
|
||||
platform: NodeJS.Platform = "linux",
|
||||
) {
|
||||
const disabledSwitches: string[] = [];
|
||||
const inspectionErrors: unknown[] = [];
|
||||
|
||||
configureLinuxSandbox({
|
||||
platform,
|
||||
resourcesPath: "/opt/Paseo/resources",
|
||||
statSandbox: () => {
|
||||
if (sandbox instanceof Error) {
|
||||
throw sandbox;
|
||||
}
|
||||
return sandbox;
|
||||
},
|
||||
disableSandbox: () => disabledSwitches.push("no-sandbox"),
|
||||
reportInspectionError: (error) => inspectionErrors.push(error),
|
||||
});
|
||||
|
||||
return { disabledSwitches, inspectionErrors };
|
||||
}
|
||||
|
||||
function createFileSystemError(code: string): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error(code);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("configureLinuxSandbox", () => {
|
||||
it("disables the sandbox when an AppImage mount strips SUID", () => {
|
||||
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the sandbox for a root-owned 4755 helper", () => {
|
||||
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
|
||||
disabledSwitches: [],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the sandbox when a SUID helper is not root-owned", () => {
|
||||
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the sandbox when the helper is missing", () => {
|
||||
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the sandbox and reports unexpected inspection failures", () => {
|
||||
const permissionError = createFileSystemError("EACCES");
|
||||
|
||||
expect(configureWithSandbox(permissionError)).toEqual({
|
||||
disabledSwitches: [],
|
||||
inspectionErrors: [permissionError],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inspect or configure the sandbox outside Linux", () => {
|
||||
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
|
||||
disabledSwitches: [],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
45
packages/desktop/src/system/linux-sandbox.ts
Normal file
45
packages/desktop/src/system/linux-sandbox.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import path from "node:path";
|
||||
|
||||
const REQUIRED_SANDBOX_MODE = 0o4755;
|
||||
const PERMISSION_BITS = 0o7777;
|
||||
|
||||
interface SandboxMetadata {
|
||||
mode: number;
|
||||
uid: number;
|
||||
}
|
||||
|
||||
interface LinuxSandboxConfiguration {
|
||||
platform: NodeJS.Platform;
|
||||
resourcesPath: string;
|
||||
statSandbox: (sandboxPath: string) => SandboxMetadata;
|
||||
disableSandbox: () => void;
|
||||
reportInspectionError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function isMissingSandbox(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void {
|
||||
if (input.platform !== "linux") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
|
||||
const sandbox = input.statSandbox(sandboxPath);
|
||||
const hasUsableSandbox =
|
||||
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
|
||||
|
||||
if (!hasUsableSandbox) {
|
||||
input.disableSandbox();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingSandbox(error)) {
|
||||
input.disableSandbox();
|
||||
return;
|
||||
}
|
||||
|
||||
input.reportInspectionError(error);
|
||||
}
|
||||
}
|
||||
@@ -168,4 +168,14 @@ describe("project command-center protocol", () => {
|
||||
expect(parsed.features?.projectGithubClone).toBeUndefined();
|
||||
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses the agent thinking update capability", () => {
|
||||
const parsed = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "server-new",
|
||||
features: { agentThinkingUpdate: true },
|
||||
});
|
||||
|
||||
expect(parsed.features?.agentThinkingUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2792,6 +2792,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: z.boolean().optional(),
|
||||
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
|
||||
agentThinkingUpdate: z.boolean().optional(),
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: z.boolean().optional(),
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -3914,8 +3914,8 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
actions: [
|
||||
expect.objectContaining({
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
id: "dismiss",
|
||||
label: "Dismiss",
|
||||
behavior: "deny",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -3979,6 +3979,216 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("replaces a pending synthetic plan approval when a later plan completes", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-first" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the first plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-second" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the revised plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "plan",
|
||||
input: { plan: "- Implement the revised plan" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
const pendingPlan = session.getPendingPermissions()[0];
|
||||
expect(pendingPlan).toBeDefined();
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") return { data: ["test-thread"] };
|
||||
if (method === "turn/start") return {};
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
await session.startTurn("Revise the plan to include tests");
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(events).toContainEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId: pendingPlan!.id,
|
||||
resolution: {
|
||||
behavior: "deny",
|
||||
message: "Dismissed by a new prompt",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
const pendingPlan = session.getPendingPermissions()[0];
|
||||
expect(pendingPlan).toBeDefined();
|
||||
|
||||
let continuePromptSetup: (() => void) | undefined;
|
||||
let markPromptSetupStarted: (() => void) | undefined;
|
||||
const promptSetupStarted = new Promise<void>((resolve) => {
|
||||
markPromptSetupStarted = resolve;
|
||||
});
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") {
|
||||
markPromptSetupStarted?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
continuePromptSetup = resolve;
|
||||
});
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") return {};
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
const startTurn = session.startTurn("Revise the plan");
|
||||
await promptSetupStarted;
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
await expect(
|
||||
session.respondToPermission(pendingPlan!.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`No pending Codex app-server permission request with id '${pendingPlan!.id}'`,
|
||||
);
|
||||
|
||||
continuePromptSetup?.();
|
||||
await startTurn;
|
||||
});
|
||||
|
||||
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
let acceptPrompt: (() => void) | undefined;
|
||||
let markPromptRequested: (() => void) | undefined;
|
||||
const promptRequested = new Promise<void>((resolve) => {
|
||||
markPromptRequested = resolve;
|
||||
});
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") return { data: ["test-thread"] };
|
||||
if (method === "turn/start") {
|
||||
markPromptRequested?.();
|
||||
return await new Promise<void>((resolve) => {
|
||||
acceptPrompt = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
const startTurn = session.startTurn("Revise the plan");
|
||||
await promptRequested;
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the newer plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
acceptPrompt?.();
|
||||
await startTurn;
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([
|
||||
expect.objectContaining({ input: { plan: "- Implement the newer plan" } }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
asInternals(session).handleNotification("turn/started", {
|
||||
turn: { id: "turn-plan-pending" },
|
||||
});
|
||||
asInternals(session).handleNotification("turn/plan/updated", {
|
||||
plan: [{ step: "Implement the original plan", status: "pending" }],
|
||||
});
|
||||
asInternals(session).handleNotification("turn/completed", {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
const pendingPlan = session.getPendingPermissions()[0];
|
||||
expect(pendingPlan).toBeDefined();
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({
|
||||
request: async (method) => {
|
||||
if (method === "thread/loaded/list") return { data: ["test-thread"] };
|
||||
if (method === "turn/start") throw new Error("Prompt rejected");
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(session.startTurn("Revise the plan")).rejects.toThrow("Prompt rejected");
|
||||
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits imageView paths with spaces as valid assistant markdown images", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -963,8 +963,8 @@ function buildPlanPermissionActions(options?: {
|
||||
}): AgentPermissionAction[] {
|
||||
const actions: AgentPermissionAction[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
id: "dismiss",
|
||||
label: "Dismiss",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
@@ -3092,6 +3092,13 @@ interface CodexSubAgentCallState {
|
||||
childThreadIds: Set<string>;
|
||||
}
|
||||
|
||||
interface CodexPendingPermissionHandler {
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
|
||||
export class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
@@ -3115,15 +3122,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||
private pendingPermissionHandlers = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
>();
|
||||
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
private pendingAgentMessages = new Map<string, string>();
|
||||
private pendingReasoning = new Map<string, string[]>();
|
||||
@@ -3430,6 +3429,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emitSyntheticPlanApprovalRequest(planText: string): void {
|
||||
this.dismissPendingPlanApprovals("Superseded by a newer plan");
|
||||
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
@@ -3837,30 +3838,31 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
}
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
|
||||
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.currentTurnId = null;
|
||||
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
}
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.currentTurnId = null;
|
||||
|
||||
this.logTurnStartSummary({
|
||||
turnId,
|
||||
thinkingOptionId: turnStart.thinkingOptionId,
|
||||
@@ -3871,13 +3873,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
||||
return { turnId };
|
||||
} catch (error) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
|
||||
@@ -4128,12 +4129,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private handlePlanPermissionResponse(params: {
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
pending: {
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
};
|
||||
pending: CodexPendingPermissionHandler;
|
||||
pendingRequest: AgentPermissionRequest | null;
|
||||
}): AgentPermissionResult | void {
|
||||
const { requestId, response, pending, pendingRequest } = params;
|
||||
@@ -4144,6 +4140,23 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
this.resolvePlanPermission(requestId, response);
|
||||
if (followUpPrompt) {
|
||||
return { followUpPrompt };
|
||||
}
|
||||
}
|
||||
|
||||
private dismissPendingPlanApprovals(message: string): void {
|
||||
const requestIds = Array.from(this.pendingPermissionHandlers)
|
||||
.filter(([, pending]) => pending.kind === "plan")
|
||||
.map(([requestId]) => requestId);
|
||||
|
||||
for (const requestId of requestIds) {
|
||||
this.resolvePlanPermission(requestId, { behavior: "deny", message });
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
@@ -4151,11 +4164,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: response,
|
||||
resolution,
|
||||
});
|
||||
if (followUpPrompt) {
|
||||
return { followUpPrompt };
|
||||
}
|
||||
}
|
||||
|
||||
private emitDeniedToolCallTimelineEvent(params: {
|
||||
|
||||
@@ -1540,6 +1540,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: true,
|
||||
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
|
||||
agentThinkingUpdate: true,
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: true,
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
|
||||
@@ -253,7 +253,7 @@ function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>
|
||||
}
|
||||
|
||||
function removeZshShellIntegrationRuntimeDir(): void {
|
||||
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh`), {
|
||||
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
@@ -272,7 +272,9 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
||||
expect(resolvedEnv.TERM).toBe("xterm-256color");
|
||||
expect(resolvedEnv.TERM_PROGRAM).toBe("kitty");
|
||||
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
|
||||
expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir");
|
||||
expect(resolvedEnv.ZDOTDIR).toBe(
|
||||
join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`),
|
||||
);
|
||||
expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true);
|
||||
expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -384,7 +384,7 @@ function resolveZshShellIntegrationRuntimeDir(): string {
|
||||
} catch {
|
||||
// keep fallback
|
||||
}
|
||||
return join(tmpdir(), `${username}-paseo-zsh`);
|
||||
return join(tmpdir(), `${username}-paseo-zsh-${process.pid}`);
|
||||
}
|
||||
|
||||
function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string {
|
||||
|
||||
57
scripts/ci-workflow.test.mjs
Normal file
57
scripts/ci-workflow.test.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url);
|
||||
|
||||
function jobBlocks(source) {
|
||||
const jobs = new Map();
|
||||
let currentJob;
|
||||
|
||||
for (const line of source.split("\n")) {
|
||||
const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line);
|
||||
if (jobMatch) {
|
||||
currentJob = jobMatch[1];
|
||||
jobs.set(currentJob, []);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) {
|
||||
jobs.get(currentJob).push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
test("matrix jobs expand before change gating", () => {
|
||||
const workflow = readFileSync(workflowPath, "utf8");
|
||||
const gatedMatrixJobs = [...jobBlocks(workflow)]
|
||||
.filter(([, lines]) => {
|
||||
const hasMatrix = lines.some((line) => line.startsWith(" matrix:"));
|
||||
const unsafeJobCondition = lines.some(
|
||||
(line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}",
|
||||
);
|
||||
return hasMatrix && unsafeJobCondition;
|
||||
})
|
||||
.map(([jobId]) => jobId);
|
||||
|
||||
assert.deepEqual(
|
||||
gatedMatrixJobs,
|
||||
[],
|
||||
"change-based job conditions skip a matrix before GitHub can emit its interpolated check names",
|
||||
);
|
||||
});
|
||||
|
||||
test("change gating allows superseded workflow runs to cancel", () => {
|
||||
const workflow = readFileSync(workflowPath, "utf8");
|
||||
const cancellationBlockingJobs = [...jobBlocks(workflow)]
|
||||
.filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()")))
|
||||
.map(([jobId]) => jobId);
|
||||
|
||||
assert.deepEqual(
|
||||
cancellationBlockingJobs,
|
||||
[],
|
||||
"always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user