Compare commits
8
Commits
v0.1.0
...
613a2a7f72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613a2a7f72 | ||
|
|
659915d309 | ||
|
|
572bd9f8eb | ||
|
|
d7e4fd0938 | ||
|
|
13efa54f16 | ||
|
|
28092194fe | ||
|
|
ff5a7a644f | ||
|
|
e35aea5045 |
No files matched your search
+42
-20
@@ -1,4 +1,10 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
# Three levels of testing:
|
||||||
|
# - every PR: lint, unit tests (16 property walks), integration tests, and
|
||||||
|
# the smoke end-to-end tests (`smoke` marker);
|
||||||
|
# - a PR labelled e2e:full, a push to main and a manual run: all 48 walks
|
||||||
|
# and the whole end-to-end suite;
|
||||||
|
# - a published release: the full set again before its image (release.yml).
|
||||||
on:
|
on:
|
||||||
# Only main on push. A PR branch is already covered by the pull_request
|
# Only main on push. A PR branch is already covered by the pull_request
|
||||||
# event, and running both fired two identical suites concurrently — which
|
# event, and running both fired two identical suites concurrently — which
|
||||||
@@ -7,11 +13,18 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
# labeled: adding e2e:full runs the full set; any other label, nothing.
|
||||||
|
types: [opened, synchronize, reopened, labeled]
|
||||||
# Manual re-run, e.g. after rotating a credential or raising a budget cap.
|
# Manual re-run, e.g. after rotating a credential or raising a budget cap.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Full on e2e:full, and outside PRs.
|
||||||
|
FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e:full') }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
if: github.event.action != 'labeled' || github.event.label.name == 'e2e:full'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -26,12 +39,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Install sshd
|
- name: Install sshd
|
||||||
# The tests reach "the build VM" over real SSH, through a throwaway
|
# The tests reach "the build VM" over real SSH, through a throwaway
|
||||||
# sshd the test fixture starts (nisse_dev/testing.py).
|
# sshd the test fixture starts (nisse_dev/testing.py). Skipped when the image has it; otherwise from
|
||||||
run: |
|
# a kernel.org mirror: archive.ubuntu.com has been slow enough to add
|
||||||
SUDO=$(command -v sudo || true)
|
# minutes to every job.
|
||||||
$SUDO apt-get update -qq
|
run: scripts/ci-sshd.sh
|
||||||
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
|
||||||
$SUDO mkdir -p /run/sshd
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: uv run ruff check .
|
run: uv run ruff check .
|
||||||
@@ -40,7 +51,12 @@ jobs:
|
|||||||
run: uv run ruff format --check .
|
run: uv run ruff format --check .
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: uv run pytest -m "not integration and not e2e"
|
# Across the runner's cores (pytest-xdist). On a PR, 16 property
|
||||||
|
# walks from a seed that moves with the run number; all 48 otherwise.
|
||||||
|
env:
|
||||||
|
NISSE_WALK_SEEDS: ${{ env.FULL == 'true' && '48' || '16' }}
|
||||||
|
NISSE_WALK_FIRST: ${{ env.FULL == 'true' && '0' || github.run_number }}
|
||||||
|
run: uv run pytest -n auto -m "not integration and not e2e"
|
||||||
|
|
||||||
- name: Integration tests
|
- name: Integration tests
|
||||||
# Everything but the secrets is in tests/integration/nisse-test.yaml,
|
# Everything but the secrets is in tests/integration/nisse-test.yaml,
|
||||||
@@ -58,12 +74,15 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
# The end-to-end suite (scripts/e2e.sh): the whole agent against real
|
# The end-to-end suite (scripts/e2e.sh): the whole agent against real
|
||||||
# services, every role on a local model, so it spends nothing but keeps
|
# services, every role on a local model, so it spends nothing but keeps
|
||||||
# the RTX 4090 busy for ~25 minutes. On PRs only, and only after the
|
# the RTX 4090 busy: ~10 minutes for the smoke tests, ~25+ for all. On
|
||||||
# checks pass: a PR that's red already doesn't spend the GPU. There is
|
# PRs and manual runs, and only after the checks pass: a PR that's red
|
||||||
# one runner and Gitea doesn't honour cancel-in-progress, so a run a
|
# already doesn't spend the GPU. There is one runner and Gitea doesn't
|
||||||
# newer push supersedes holds the runner until it's cancelled by hand.
|
# honour cancel-in-progress, so a run a newer push supersedes holds the
|
||||||
# Locally, run only what you're writing (-k) or diagnosing (--lf).
|
# runner until it's cancelled by hand. Locally, run only what you're
|
||||||
if: github.event_name != 'push'
|
# writing (-k) or diagnosing (--lf).
|
||||||
|
if: >-
|
||||||
|
github.event_name != 'push'
|
||||||
|
&& (github.event.action != 'labeled' || github.event.label.name == 'e2e:full')
|
||||||
needs: checks
|
needs: checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
@@ -79,12 +98,10 @@ jobs:
|
|||||||
run: uv sync --all-packages
|
run: uv sync --all-packages
|
||||||
|
|
||||||
- name: Install sshd
|
- name: Install sshd
|
||||||
# The build VM is a throwaway sshd here (scripts/e2e.sh --local).
|
# The build VM is a throwaway sshd here (scripts/e2e.sh --local). Skipped when the image has it; otherwise from
|
||||||
run: |
|
# a kernel.org mirror: archive.ubuntu.com has been slow enough to add
|
||||||
SUDO=$(command -v sudo || true)
|
# minutes to every job.
|
||||||
$SUDO apt-get update -qq
|
run: scripts/ci-sshd.sh
|
||||||
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
|
||||||
$SUDO mkdir -p /run/sshd
|
|
||||||
|
|
||||||
- name: End-to-end tests
|
- name: End-to-end tests
|
||||||
env:
|
env:
|
||||||
@@ -93,4 +110,9 @@ jobs:
|
|||||||
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_TOKEN }}
|
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_TOKEN }}
|
||||||
NISSE_OPERATOR_TOKEN: ${{ secrets.NISSE_OPERATOR_TOKEN }}
|
NISSE_OPERATOR_TOKEN: ${{ secrets.NISSE_OPERATOR_TOKEN }}
|
||||||
NISSE_E2E_RUNS: /tmp/nisse-e2e-runs
|
NISSE_E2E_RUNS: /tmp/nisse-e2e-runs
|
||||||
run: scripts/e2e.sh --local -v
|
run: |
|
||||||
|
if [ "$FULL" = true ]; then
|
||||||
|
scripts/e2e.sh --local -v
|
||||||
|
else
|
||||||
|
scripts/e2e.sh --local -v -m "e2e and smoke"
|
||||||
|
fi
|
||||||
@@ -3,7 +3,8 @@ name: Release
|
|||||||
# registry (Development Plan M1.5, X4): gitea.dongar.net/dev/nisse-dev
|
# registry (Development Plan M1.5, X4): gitea.dongar.net/dev/nisse-dev
|
||||||
# tagged with the version (the release tag without its v) and latest. Cut a
|
# tagged with the version (the release tag without its v) and latest. Cut a
|
||||||
# release in Gitea (Releases → New release, tag vX.Y.Z); a tag pushed for any
|
# release in Gitea (Releases → New release, tag vX.Y.Z); a tag pushed for any
|
||||||
# other reason builds nothing.
|
# other reason builds nothing. The full test set runs first: no image without
|
||||||
|
# it passing.
|
||||||
#
|
#
|
||||||
# The registry needs a personal access token with write:package
|
# The registry needs a personal access token with write:package
|
||||||
# (REGISTRY_TOKEN, an organization secret of `dev`, belonging to the
|
# (REGISTRY_TOKEN, an organization secret of `dev`, belonging to the
|
||||||
@@ -23,7 +24,46 @@ env:
|
|||||||
IMAGE: gitea.dongar.net/dev/nisse-dev
|
IMAGE: gitea.dongar.net/dev/nisse-dev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
tests:
|
||||||
|
# Everything, before an image exists: lint, unit tests with all 48
|
||||||
|
# property walks, integration tests and the whole end-to-end suite.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 150
|
||||||
|
env:
|
||||||
|
NISSE_LLM_TOKEN: ${{ secrets.NISSE_LLM_TOKEN }}
|
||||||
|
NISSE_MATRIX_TOKEN: ${{ secrets.NISSE_MATRIX_TOKEN }}
|
||||||
|
NISSE_GITEA_TOKEN: ${{ secrets.NISSE_GITEA_TOKEN }}
|
||||||
|
NISSE_OPERATOR_TOKEN: ${{ secrets.NISSE_OPERATOR_TOKEN }}
|
||||||
|
NISSE_E2E_RUNS: /tmp/nisse-e2e-runs
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync workspace
|
||||||
|
run: uv sync --all-packages
|
||||||
|
|
||||||
|
- name: Install sshd
|
||||||
|
run: scripts/ci-sshd.sh
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check . && uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: uv run pytest -n auto -m "not integration and not e2e"
|
||||||
|
|
||||||
|
- name: Integration tests
|
||||||
|
run: uv run pytest -m "integration and not e2e" -rs
|
||||||
|
|
||||||
|
- name: End-to-end tests
|
||||||
|
run: scripts/e2e.sh --local -v
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
+13
-8
@@ -10,6 +10,12 @@
|
|||||||
# docker build -t nisse-dev . the agent
|
# docker build -t nisse-dev . the agent
|
||||||
# docker build --target test -t nisse-dev:test . plus the tests, for
|
# docker build --target test -t nisse-dev:test . plus the tests, for
|
||||||
# scripts/e2e.sh --image
|
# scripts/e2e.sh --image
|
||||||
|
# docker build --target test --build-arg TEST_BASE=<published image> \
|
||||||
|
# -t nisse-dev:test . the tests over a release as
|
||||||
|
# published (M1.5 acceptance)
|
||||||
|
|
||||||
|
# What the test stage stacks on: this build's agent, or a published image.
|
||||||
|
ARG TEST_BASE=runtime
|
||||||
|
|
||||||
FROM python:3.12-slim AS build
|
FROM python:3.12-slim AS build
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /usr/local/bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /usr/local/bin/uv
|
||||||
@@ -40,9 +46,10 @@ VOLUME /var/lib/nisse-dev
|
|||||||
USER nisse
|
USER nisse
|
||||||
ENTRYPOINT ["nisse-dev"]
|
ENTRYPOINT ["nisse-dev"]
|
||||||
|
|
||||||
# The image's own package plus the tests, so the end-to-end suite runs
|
# The tests over an image's own package (this build's, or a published one,
|
||||||
# against what was built, not the source tree.
|
# TEST_BASE): only the test tools are added, never the agent reinstalled, so
|
||||||
FROM runtime AS test
|
# the end-to-end suite runs against what was built, not the source beside it.
|
||||||
|
FROM ${TEST_BASE} AS test
|
||||||
USER root
|
USER root
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git openssh-server \
|
&& apt-get install -y --no-install-recommends git openssh-server \
|
||||||
@@ -57,11 +64,9 @@ COPY nisse-ops nisse-ops
|
|||||||
COPY tests tests
|
COPY tests tests
|
||||||
COPY scripts scripts
|
COPY scripts scripts
|
||||||
COPY conftest.py ./
|
COPY conftest.py ./
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/opt/nisse
|
ENV UV_PROJECT_ENVIRONMENT=/opt/nisse UV_NO_SYNC=1
|
||||||
# Test tools too, all installed (not editable), and `uv run` never re-syncs:
|
RUN uv export --frozen --only-group dev --no-hashes --no-emit-project > /tmp/dev.txt \
|
||||||
# the tests import the package as built, not the source tree beside them.
|
&& uv pip install --quiet --python /opt/nisse/bin/python -r /tmp/dev.txt
|
||||||
RUN uv sync --frozen --all-packages --no-editable --inexact
|
|
||||||
ENV UV_NO_SYNC=1
|
|
||||||
ENTRYPOINT []
|
ENTRYPOINT []
|
||||||
|
|
||||||
# The default target: the agent itself.
|
# The default target: the agent itself.
|
||||||
|
|||||||
@@ -73,10 +73,14 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
|
|||||||
`CLAUDE.md`. A milestone marked **needs discussion** starts with a design
|
`CLAUDE.md`. A milestone marked **needs discussion** starts with a design
|
||||||
discussion first.
|
discussion first.
|
||||||
- **Every change comes with tests,** through `scripts/check.sh`.
|
- **Every change comes with tests,** through `scripts/check.sh`.
|
||||||
- **The end-to-end test runs on every PR** (M1.6): the real agent, every role on a
|
- **Testing has three levels** (M1.6): every PR runs the checks (unit tests across
|
||||||
local model (so it costs nothing per call), a repository of the run's own and a
|
the runner's cores, 16 property walks from a moving seed, integration) and the
|
||||||
real build host, driven by a scripted operator. CI runs it as its own job on pull
|
end-to-end **smoke** tests; a PR labelled `e2e:full`, a push to `main` and a
|
||||||
requests, after the checks pass. There is one runner, and a run a newer push
|
manual run add all 48 walks and the whole end-to-end suite; a published release
|
||||||
|
runs that full set before its image is built. The end-to-end tests drive the
|
||||||
|
real agent, every role on a local model (so they cost nothing per call), in a
|
||||||
|
repository of the run's own, with a scripted operator; CI runs them after the
|
||||||
|
checks pass. There is one runner, and a run a newer push
|
||||||
supersedes has to be cancelled by hand. Locally only the tests being written
|
supersedes has to be cancelled by hand. Locally only the tests being written
|
||||||
(`-k`) or diagnosed (`--lf`); `scripts/e2e.sh` reports what a run spent. Manual
|
(`-k`) or diagnosed (`--lf`); `scripts/e2e.sh` reports what a run spent. Manual
|
||||||
live runs are for the new loop's first runs and phase ends. Findings are filed as
|
live runs are for the new loop's first runs and phase ends. Findings are filed as
|
||||||
@@ -271,8 +275,9 @@ in prose, asking mid-work (L5, #42, #77).
|
|||||||
- Deferred: config reload without a restart (with M2.5's entry step if wanted);
|
- Deferred: config reload without a restart (with M2.5's entry step if wanted);
|
||||||
the trust dial's configuration (M3.3).
|
the trust dial's configuration (M3.3).
|
||||||
|
|
||||||
**M1.5 The agent as a container image.** *Pre-approved.* **Built** (this PR);
|
**M1.5 The agent as a container image.** *Pre-approved.* **Done** (#115; accepted
|
||||||
accepted once a tag's image passes the e2e suite.
|
2026-10-07: `v0.1.0`'s published image passed the whole e2e suite, 9 of 9, run with
|
||||||
|
the release's own tests over it).
|
||||||
- A Dockerfile for `nisse-dev`, stateless with a volume for state; a Gitea Actions
|
- A Dockerfile for `nisse-dev`, stateless with a volume for state; a Gitea Actions
|
||||||
workflow that builds it and pushes to Gitea's registry on each release tag (X4).
|
workflow that builds it and pushes to Gitea's registry on each release tag (X4).
|
||||||
- `bringup.sh` runs the image instead of a venv.
|
- `bringup.sh` runs the image instead of a venv.
|
||||||
@@ -283,6 +288,10 @@ accepted once a tag's image passes the e2e suite.
|
|||||||
package, for `scripts/e2e.sh --image REF`. Images are built only on release:
|
package, for `scripts/e2e.sh --image REF`. Images are built only on release:
|
||||||
`release.yml` pushes `gitea.dongar.net/dev/nisse-dev:<version>` and `:latest`
|
`release.yml` pushes `gitea.dongar.net/dev/nisse-dev:<version>` and `:latest`
|
||||||
on a published release (`REGISTRY_TOKEN`, the `dev` organization's).
|
on a published release (`REGISTRY_TOKEN`, the `dev` organization's).
|
||||||
|
- Acceptance runs the tests over a published image: `docker build --target test
|
||||||
|
--build-arg TEST_BASE=gitea.dongar.net/dev/nisse-dev:<version>` from the
|
||||||
|
release's checkout, then `scripts/e2e.sh --image`; the test stage adds only the
|
||||||
|
test tools, so the agent under test is the one published.
|
||||||
- The live bring-up's switch to the image waits for the next live test (it needs
|
- The live bring-up's switch to the image waits for the next live test (it needs
|
||||||
its own test repository too); `bringup.sh plan` shows what it will run.
|
its own test repository too); `bringup.sh plan` shows what it will run.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from nisse_dev.canonical import (
|
|||||||
AssistantText,
|
AssistantText,
|
||||||
DelegationCompleted,
|
DelegationCompleted,
|
||||||
DelegationStarted,
|
DelegationStarted,
|
||||||
|
Fact,
|
||||||
PlanSubmitted,
|
PlanSubmitted,
|
||||||
StoredEvent,
|
StoredEvent,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -414,6 +415,15 @@ class Delegation:
|
|||||||
# and its tree state is unknown. Treat it as a failure rather
|
# and its tree state is unknown. Treat it as a failure rather
|
||||||
# than inventing a handback on its behalf.
|
# than inventing a handback on its behalf.
|
||||||
if any(isinstance(e, AssistantText) for e in produced):
|
if any(isinstance(e, AssistantText) for e in produced):
|
||||||
|
# Said something but did nothing: told so, as the
|
||||||
|
# harness, so the next request ends on the user's side
|
||||||
|
# (models refuse assistant prefill).
|
||||||
|
transcript.as_actor("orchestrator").append(
|
||||||
|
Fact(
|
||||||
|
text="Your last reply was prose. Use your tools to "
|
||||||
|
"do the work, then call hand_back."
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
return DelegationResult(
|
return DelegationResult(
|
||||||
"failed",
|
"failed",
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ asking is better than guessing.
|
|||||||
Before each group of tool calls, say in a sentence what you're doing and why:
|
Before each group of tool calls, say in a sentence what you're doing and why:
|
||||||
the operator sees it as your progress.
|
the operator sees it as your progress.
|
||||||
|
|
||||||
|
When the operator says something that needs an answer but no change (a
|
||||||
|
remark, a status question), answer with reply: the item goes back to what it
|
||||||
|
was waiting for. Every reply of yours is a tool call, never bare prose.
|
||||||
|
|
||||||
The project's documents are in its repository: read ARCHITECTURE.md and any
|
The project's documents are in its repository: read ARCHITECTURE.md and any
|
||||||
build plans under docs/plans/ before you restate.
|
build plans under docs/plans/ before you restate.
|
||||||
|
|
||||||
@@ -587,6 +591,8 @@ class LoopAgent:
|
|||||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||||
elif new and item.position == WAITING:
|
elif new and item.position == WAITING:
|
||||||
position = READY
|
position = READY
|
||||||
|
# A comment can be answered with a reply, back to this wait.
|
||||||
|
loop = replace(loop, back_to=loop.wait, back_note=loop.note)
|
||||||
if position != item.position or loop != loop_of(item):
|
if position != item.position or loop != loop_of(item):
|
||||||
if position == READY:
|
if position == READY:
|
||||||
loop = replace(loop, wait="", note="")
|
loop = replace(loop, wait="", note="")
|
||||||
@@ -824,6 +830,19 @@ class LoopAgent:
|
|||||||
elif not self._store.active_item():
|
elif not self._store.active_item():
|
||||||
self._transport.post("Nothing is running.", thread=msg.thread)
|
self._transport.post("Nothing is running.", thread=msg.thread)
|
||||||
return
|
return
|
||||||
|
if command in ("status", "details"):
|
||||||
|
# About the item, not a reply to it: nothing changes, a
|
||||||
|
# countdown runs on (L5). Recorded at the agent's level.
|
||||||
|
self._store.transcript(None, "operator").append(
|
||||||
|
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||||
|
)
|
||||||
|
said = (
|
||||||
|
self._details(item)
|
||||||
|
if command == "details"
|
||||||
|
else self._status_line(item, [i.id for i in self._queue()])
|
||||||
|
)
|
||||||
|
self._transport.post(said, thread=loop_of(item).thread)
|
||||||
|
return
|
||||||
self._store.transcript(item.id, "operator").append(
|
self._store.transcript(item.id, "operator").append(
|
||||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||||
)
|
)
|
||||||
@@ -844,8 +863,6 @@ class LoopAgent:
|
|||||||
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
|
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
|
||||||
elif command == "done" and loop.new_project:
|
elif command == "done" and loop.new_project:
|
||||||
self._provisioned(item, loop)
|
self._provisioned(item, loop)
|
||||||
elif command == "details":
|
|
||||||
self._transport.post(self._details(item), thread=loop.thread)
|
|
||||||
elif (
|
elif (
|
||||||
item.position == WAITING
|
item.position == WAITING
|
||||||
and item.scope != GENERAL
|
and item.scope != GENERAL
|
||||||
@@ -903,12 +920,13 @@ class LoopAgent:
|
|||||||
)
|
)
|
||||||
self._go(item, loop, f"{msg.sender} (in plain language)")
|
self._go(item, loop, f"{msg.sender} (in plain language)")
|
||||||
elif item.position == WAITING:
|
elif item.position == WAITING:
|
||||||
# An answer, or changes to a restatement: the driver reads it.
|
# An answer, changes to a restatement, or a remark: the driver
|
||||||
|
# reads it, and may reply and go back to this wait (L5).
|
||||||
save(
|
save(
|
||||||
self._store,
|
self._store,
|
||||||
item.id,
|
item.id,
|
||||||
READY,
|
READY,
|
||||||
replace(loop, wait="", note=""),
|
replace(loop, wait="", note="", back_to=loop.wait, back_note=loop.note),
|
||||||
cause="the operator answered",
|
cause="the operator answered",
|
||||||
)
|
)
|
||||||
self._schedule()
|
self._schedule()
|
||||||
@@ -1272,28 +1290,28 @@ class LoopAgent:
|
|||||||
f"{self._repos()}."
|
f"{self._repos()}."
|
||||||
)
|
)
|
||||||
queue = [i.id for i in self._queue()]
|
queue = [i.id for i in self._queue()]
|
||||||
lines = []
|
return "\n".join(self._status_line(item, queue) for item in items)
|
||||||
for item in items:
|
|
||||||
loop = loop_of(item)
|
def _status_line(self, item: WorkItem, queue: list[str]) -> str:
|
||||||
what = item.position
|
loop = loop_of(item)
|
||||||
if item.position == WAITING and loop.wait == CHAT:
|
what = item.position
|
||||||
what = "talking; your turn"
|
if item.position == WAITING and loop.wait == CHAT:
|
||||||
elif item.position == WAITING:
|
what = "talking; your turn"
|
||||||
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
elif item.position == WAITING:
|
||||||
if loop.grace_until:
|
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
||||||
left = max(0, int(loop.grace_until - self._clock()) // 60)
|
if loop.grace_until:
|
||||||
what += f", going ahead in {left} min unless you reply"
|
left = max(0, int(loop.grace_until - self._clock()) // 60)
|
||||||
elif item.position == BLOCKED:
|
what += f", going ahead in {left} min unless you reply"
|
||||||
what = f"blocked by {', '.join(loop.blocked_by)}"
|
elif item.position == BLOCKED:
|
||||||
elif item.position == READY:
|
what = f"blocked by {', '.join(loop.blocked_by)}"
|
||||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
elif item.position == READY:
|
||||||
name = (
|
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||||
f'conversation "{item.title}"'
|
name = (
|
||||||
if item.scope == GENERAL
|
f'conversation "{item.title}"'
|
||||||
else f"{self._label(item)} {item.title}"
|
if item.scope == GENERAL
|
||||||
)
|
else f"{self._label(item)} {item.title}"
|
||||||
lines.append(f"{name}: {what}")
|
)
|
||||||
return "\n".join(lines)
|
return f"{name}: {what}"
|
||||||
|
|
||||||
# -- running (L1, L2) ------------------------------------------------------
|
# -- running (L1, L2) ------------------------------------------------------
|
||||||
|
|
||||||
@@ -1663,12 +1681,15 @@ class LoopAgent:
|
|||||||
CLOSE_ITEM,
|
CLOSE_ITEM,
|
||||||
*([WAIT_FOR_REVIEW] if loop.pr else []),
|
*([WAIT_FOR_REVIEW] if loop.pr else []),
|
||||||
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
|
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
|
||||||
|
*([REPLY] if loop.back_to else []),
|
||||||
*EXECUTOR_TOOLS,
|
*EXECUTOR_TOOLS,
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if loop.question:
|
if loop.question:
|
||||||
tools = [REPLY, *_LOOK]
|
tools = [REPLY, *_LOOK]
|
||||||
|
elif loop.back_to and not loop.started:
|
||||||
|
tools = [*tools, REPLY] # before the go-ahead, a remark answered
|
||||||
roles = self._roles_of(item)
|
roles = self._roles_of(item)
|
||||||
workspace = project.workspace
|
workspace = project.workspace
|
||||||
system = SYSTEM.format(
|
system = SYSTEM.format(
|
||||||
@@ -1771,6 +1792,13 @@ class LoopAgent:
|
|||||||
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
|
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
elif outcome.kind == "wait" and outcome.tool == "reply" and loop.back_to:
|
||||||
|
# A project item's reply: answered where asked (the thread or the
|
||||||
|
# PR), then back to the very wait the message interrupted (L5).
|
||||||
|
self._answer_where_asked(item, loop, str(args.get("text", "")).strip())
|
||||||
|
self._wait(
|
||||||
|
item, replace(loop, wait=loop.back_to, note=loop.back_note), None
|
||||||
|
)
|
||||||
elif outcome.kind == "wait" and outcome.tool == "reply":
|
elif outcome.kind == "wait" and outcome.tool == "reply":
|
||||||
text = str(args.get("text", "")).strip()
|
text = str(args.get("text", "")).strip()
|
||||||
self._transport.post(text, thread=loop.thread)
|
self._transport.post(text, thread=loop.thread)
|
||||||
@@ -2068,6 +2096,8 @@ class LoopAgent:
|
|||||||
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
||||||
if say:
|
if say:
|
||||||
self._transport.post(say, thread=loop.thread)
|
self._transport.post(say, thread=loop.thread)
|
||||||
|
# Waiting again: whatever woke the item is settled.
|
||||||
|
loop = replace(loop, question=False, back_to="", back_note="")
|
||||||
save(self._store, item.id, WAITING, loop, cause=f"waiting for {loop.wait}")
|
save(self._store, item.id, WAITING, loop, cause=f"waiting for {loop.wait}")
|
||||||
|
|
||||||
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from nisse_dev.cancel import Cancel, skip_calls
|
from nisse_dev.cancel import Cancel, skip_calls
|
||||||
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult
|
from nisse_dev.canonical import AssistantText, Fact, ToolCall, ToolResult
|
||||||
from nisse_dev.llm import LLMClient, LLMError
|
from nisse_dev.llm import LLMClient, LLMError
|
||||||
from nisse_dev.loop.tools import WAITING, names
|
from nisse_dev.loop.tools import WAITING, names
|
||||||
from nisse_dev.state import Transcript
|
from nisse_dev.state import Transcript
|
||||||
@@ -100,7 +100,10 @@ class Driver:
|
|||||||
if nudged:
|
if nudged:
|
||||||
return RunOutcome("failed", note="kept replying in prose")
|
return RunOutcome("failed", note="kept replying in prose")
|
||||||
nudged = True
|
nudged = True
|
||||||
system = f"{system}\n\n{_PROSE_NUDGE}"
|
# In the conversation, as the harness: a request must end on
|
||||||
|
# the user's side (models refuse assistant prefill), and the
|
||||||
|
# nudge is then in the log as what it was.
|
||||||
|
transcript.as_actor("orchestrator").append(Fact(text=_PROSE_NUDGE))
|
||||||
continue
|
continue
|
||||||
for index, call in enumerate(calls):
|
for index, call in enumerate(calls):
|
||||||
if self._cancel.requested:
|
if self._cancel.requested:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from dataclasses import replace
|
|||||||
from nisse_dev.canonical import (
|
from nisse_dev.canonical import (
|
||||||
AssistantText,
|
AssistantText,
|
||||||
DelegationCompleted,
|
DelegationCompleted,
|
||||||
|
DelegationStarted,
|
||||||
Fact,
|
Fact,
|
||||||
GateOpened,
|
GateOpened,
|
||||||
PlanSubmitted,
|
PlanSubmitted,
|
||||||
@@ -105,6 +106,13 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
|||||||
if viewer == "helper":
|
if viewer == "helper":
|
||||||
out.append(_said(stored, "approved plan", event.plan))
|
out.append(_said(stored, "approved plan", event.plan))
|
||||||
continue
|
continue
|
||||||
|
if isinstance(event, DelegationStarted):
|
||||||
|
# The harness handing a task over: to the helper given it, a
|
||||||
|
# message from the harness, never its own turn (a request must
|
||||||
|
# end on the user's side; models refuse assistant prefill).
|
||||||
|
if actor == viewer:
|
||||||
|
out.append(_said(stored, "orchestrator", f"your task: {event.brief}"))
|
||||||
|
continue
|
||||||
if actor == viewer:
|
if actor == viewer:
|
||||||
out.append(stored)
|
out.append(stored)
|
||||||
elif actor == "operator" and isinstance(event, UserMessage):
|
elif actor == "operator" and isinstance(event, UserMessage):
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ def assert_renderable(events: list[Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _anthropic(messages: list[dict[str, Any]]) -> None:
|
def _anthropic(messages: list[dict[str, Any]]) -> None:
|
||||||
|
# A request ends on the user's side: current models refuse assistant
|
||||||
|
# prefill ("The conversation must end with a user message"), found live
|
||||||
|
# on 2026-10-07 when the driver's prose nudge left its own reply last.
|
||||||
|
assert not messages or messages[-1]["role"] == "user", (
|
||||||
|
f"anthropic messages.{len(messages) - 1}: the conversation ends with an "
|
||||||
|
"assistant message (prefill), which the API refuses"
|
||||||
|
)
|
||||||
for index, message in enumerate(messages):
|
for index, message in enumerate(messages):
|
||||||
if message["role"] != "assistant":
|
if message["role"] != "assistant":
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""What the first live session of the new loop found (2026-10-07), each as a
|
||||||
|
test: a request ending on the assistant's side (refused by models without
|
||||||
|
prefill: the driver's prose nudge, a helper's first request), `status` in an
|
||||||
|
item's thread taken as a reply, and no way for the driver to answer a remark.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from model_contract import assert_renderable
|
||||||
|
from test_loop import RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
||||||
|
from test_loop_help import ROLES, hand_back
|
||||||
|
from test_loop_milestones import RESTATE, Forge, loop_agent, say
|
||||||
|
from test_loop_milestones import item as the_item
|
||||||
|
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||||
|
|
||||||
|
from nisse_dev.canonical import AssistantText, Fact
|
||||||
|
from nisse_dev.delegation import Caps
|
||||||
|
from nisse_dev.gitea import Comment
|
||||||
|
from nisse_dev.loop.help import Helpers
|
||||||
|
from nisse_dev.loop.items import GO_AHEAD, MERGE, WAITING, loop_of
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prose_nudge_keeps_requests_ending_on_the_user_side(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
# ScriptedModel checks every request as the API would, now including
|
||||||
|
# "the conversation must end with a user message".
|
||||||
|
h = Harness(
|
||||||
|
tmp_path,
|
||||||
|
ScriptedModel([AssistantText(text="Let me think.", model="m")], RESTATED),
|
||||||
|
FakeGitea(issue()),
|
||||||
|
)
|
||||||
|
h.agent.intake()
|
||||||
|
assert loop_of(h.item()).wait == GO_AHEAD # the nudge worked
|
||||||
|
facts = [
|
||||||
|
e.event.text
|
||||||
|
for e in h.store.transcript(h.item().id, "orchestrator").read()
|
||||||
|
if isinstance(e.event, Fact)
|
||||||
|
]
|
||||||
|
assert any("Your last reply was prose" in f for f in facts)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractHelper:
|
||||||
|
"""A helper that checks its requests as the API would."""
|
||||||
|
|
||||||
|
def __init__(self, *turns: list[Any]) -> None:
|
||||||
|
self.turns = list(turns)
|
||||||
|
|
||||||
|
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||||
|
assert_renderable(events)
|
||||||
|
return self.turns.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_helpers_requests_end_on_the_user_side(world: dict[str, Any]) -> None:
|
||||||
|
helper = ContractHelper(
|
||||||
|
[AssistantText(text="Looking around first.", model="h")], # prose
|
||||||
|
[call("write_file", path="helped.py", content="H = 1\n")],
|
||||||
|
[hand_back()],
|
||||||
|
)
|
||||||
|
model = ScriptedModel(
|
||||||
|
RESTATE,
|
||||||
|
[
|
||||||
|
call("start_milestone", name="words"),
|
||||||
|
call("delegate", task="write helped.py"),
|
||||||
|
call("finish_milestone", summary="with help", final="yes"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
helpers = Helpers(
|
||||||
|
client=helper, # type: ignore[arg-type]
|
||||||
|
roles=ROLES,
|
||||||
|
client_for=lambda model: helper, # type: ignore[arg-type,return-value]
|
||||||
|
caps=Caps(turns=5),
|
||||||
|
)
|
||||||
|
agent, transport = loop_agent(world, model, helpers=helpers)
|
||||||
|
agent.intake()
|
||||||
|
say(transport, world, "go")
|
||||||
|
forge: Forge = world["forge"]
|
||||||
|
[pr] = forge.prs
|
||||||
|
assert forge.files(pr) == ["helped.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_in_a_thread_is_not_a_reply(tmp_path: Path) -> None:
|
||||||
|
clock = Clock()
|
||||||
|
model = ScriptedModel(RESTATED) # called once only: status runs nothing
|
||||||
|
h = Harness(
|
||||||
|
tmp_path,
|
||||||
|
model,
|
||||||
|
FakeGitea(issue(labels=("bug",))),
|
||||||
|
clock=clock,
|
||||||
|
bug_grace_s=600,
|
||||||
|
)
|
||||||
|
h.agent.intake()
|
||||||
|
grace = loop_of(h.item()).grace_until
|
||||||
|
h.say("status")
|
||||||
|
assert h.posted()[-1].startswith("#14 Add count_words: waiting for go-ahead")
|
||||||
|
loop = loop_of(h.item())
|
||||||
|
assert (h.item().position, loop.wait, loop.grace_until) == (
|
||||||
|
WAITING,
|
||||||
|
GO_AHEAD,
|
||||||
|
grace,
|
||||||
|
)
|
||||||
|
h.say("details")
|
||||||
|
assert loop_of(h.item()).grace_until == grace
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remark_gets_a_reply_and_the_restatement_stays_up(tmp_path: Path) -> None:
|
||||||
|
h = Harness(
|
||||||
|
tmp_path,
|
||||||
|
ScriptedModel(
|
||||||
|
RESTATED,
|
||||||
|
[call("reply", text="Noted: I'll keep it to text.py.")],
|
||||||
|
),
|
||||||
|
FakeGitea(issue()),
|
||||||
|
)
|
||||||
|
h.agent.intake()
|
||||||
|
h.say("just a thought: keep it small")
|
||||||
|
loop = loop_of(h.item())
|
||||||
|
assert (loop.wait, loop.rev) == (GO_AHEAD, 1)
|
||||||
|
assert h.posted()[-1] == "Noted: I'll keep it to text.py."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_review_comment_can_be_answered_without_a_fix(
|
||||||
|
world: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
forge: Forge = world["forge"]
|
||||||
|
model = ScriptedModel(
|
||||||
|
RESTATE,
|
||||||
|
[
|
||||||
|
call("start_milestone", name="words"),
|
||||||
|
call("write_file", path="w.py", content="W = 1\n"),
|
||||||
|
call("finish_milestone", summary="words", final="yes"),
|
||||||
|
],
|
||||||
|
[call("reply", text="It's a constant on purpose, for the test.")],
|
||||||
|
)
|
||||||
|
agent, transport = loop_agent(world, model)
|
||||||
|
agent.intake()
|
||||||
|
say(transport, world, "go")
|
||||||
|
[pr] = forge.prs
|
||||||
|
forge.comments[pr] = [Comment(7, "don", "why a constant", "t")]
|
||||||
|
agent.watch()
|
||||||
|
agent._schedule()
|
||||||
|
loop = loop_of(the_item(world))
|
||||||
|
assert (loop.wait, loop.pr) == (MERGE, pr)
|
||||||
|
assert forge.posted[-1][0] == pr # answered on the PR, where asked
|
||||||
|
assert "It's a constant on purpose" in forge.posted[-1][1]
|
||||||
@@ -8,6 +8,7 @@ checks every property after every step. A failure prints
|
|||||||
the seed and the steps, so it replays exactly.
|
the seed and the steps, so it replays exactly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -46,8 +47,14 @@ from nisse_dev.tools import Workspace
|
|||||||
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
||||||
from nisse_dev.worker import InlineWorker
|
from nisse_dev.worker import InlineWorker
|
||||||
|
|
||||||
SEEDS = range(32)
|
# How many walks, and from which seed: all 48 by default; CI runs fewer on a
|
||||||
TWO_PROJECT_SEEDS = range(100, 116)
|
# PR, from a seed that moves with the run number, so PRs still cover new
|
||||||
|
# ground (NISSE_WALK_SEEDS, NISSE_WALK_FIRST). Two thirds walk one project,
|
||||||
|
# a third two.
|
||||||
|
_WALKS = int(os.environ.get("NISSE_WALK_SEEDS") or 48)
|
||||||
|
_FIRST = int(os.environ.get("NISSE_WALK_FIRST") or 0)
|
||||||
|
SEEDS = range(_FIRST, _FIRST + _WALKS * 2 // 3)
|
||||||
|
TWO_PROJECT_SEEDS = range(100 + _FIRST, 100 + _FIRST + _WALKS - _WALKS * 2 // 3)
|
||||||
STEPS = 30
|
STEPS = 30
|
||||||
|
|
||||||
|
|
||||||
@@ -76,8 +83,15 @@ class PolicyModel:
|
|||||||
return [call("ask_human", question=f"question {self.n}?")]
|
return [call("ask_human", question=f"question {self.n}?")]
|
||||||
if roll < 0.12:
|
if roll < 0.12:
|
||||||
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
|
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
|
||||||
if "reply" in offered and "propose_work" not in offered:
|
if "reply" in offered and not offered & {
|
||||||
|
"propose_work",
|
||||||
|
"restate",
|
||||||
|
"ask_human",
|
||||||
|
"start_milestone",
|
||||||
|
}:
|
||||||
return [call("reply", text=f"because {self.n}")] # a question run
|
return [call("reply", text=f"because {self.n}")] # a question run
|
||||||
|
if "reply" in offered and "propose_work" not in offered and roll < 0.2:
|
||||||
|
return [call("reply", text=f"noted {self.n}")] # a remark answered
|
||||||
if "reply" in offered: # a conversation: talk, or propose work
|
if "reply" in offered: # a conversation: talk, or propose work
|
||||||
if self.rng.random() < 0.1:
|
if self.rng.random() < 0.1:
|
||||||
return [
|
return [
|
||||||
@@ -251,6 +265,7 @@ class Walk:
|
|||||||
if item and loop:
|
if item and loop:
|
||||||
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
|
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
|
||||||
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
|
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
|
||||||
|
weights.update({"status-here": 1})
|
||||||
if loop.new_project:
|
if loop.new_project:
|
||||||
weights.update({"done": 2})
|
weights.update({"done": 2})
|
||||||
if item.scope != "general": # conversations aren't named by number
|
if item.scope != "general": # conversations aren't named by number
|
||||||
@@ -266,6 +281,18 @@ class Walk:
|
|||||||
elif action == "done" and loop:
|
elif action == "done" and loop:
|
||||||
# The operator says a new project is ready; no entry was added.
|
# The operator says a new project is ready; no entry was added.
|
||||||
self.transport.receive(DON, "done", thread=loop.thread)
|
self.transport.receive(DON, "done", thread=loop.thread)
|
||||||
|
elif action == "status-here" and loop:
|
||||||
|
# L5: a command about the item, not a reply: nothing changes,
|
||||||
|
# not even a countdown.
|
||||||
|
before = (item.position, loop.wait, loop.rev, loop.grace_until)
|
||||||
|
self.transport.receive(DON, "status", thread=loop.thread)
|
||||||
|
after_item = self.world["store"].work_item(item.id)
|
||||||
|
after = loop_of(after_item)
|
||||||
|
now = (after_item.position, after.wait, after.rev, after.grace_until)
|
||||||
|
assert now == before, (
|
||||||
|
f"status_changes_nothing: {before} -> {now}: "
|
||||||
|
f"seed {self.seed}, steps {self.steps}"
|
||||||
|
)
|
||||||
elif action == "question" and loop:
|
elif action == "question" and loop:
|
||||||
# L5: a question never changes the item it's asked about.
|
# L5: a question never changes the item it's asked about.
|
||||||
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
|
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ members = ["nisse-core", "nisse-dev", "nisse-ops"]
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
|
"pytest-xdist>=3",
|
||||||
"ruff>=0.6",
|
"ruff>=0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ addopts = "--strict-markers --ignore=tests/integration/seed"
|
|||||||
markers = [
|
markers = [
|
||||||
"integration: end-to-end tests that exercise installed entry points or external services",
|
"integration: end-to-end tests that exercise installed entry points or external services",
|
||||||
"e2e: the whole agent against real services, run after each milestone (scripts/e2e.sh)",
|
"e2e: the whole agent against real services, run after each milestone (scripts/e2e.sh)",
|
||||||
|
"smoke: the end-to-end tests every PR runs; the rest on e2e:full and before a release",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The bring-up's test repository (Development Plan, live runs):
|
||||||
|
# nisse-testing-operator/nisse-bringup, made as the end-to-end tests make
|
||||||
|
# theirs (tests/integration/e2e_support.py, make_repo), but kept between runs.
|
||||||
|
# Seeded with the test package, the bring-up agent's account a collaborator,
|
||||||
|
# main protected, and a deploy key of its own at $E2E/ssh/nisse-bringup.key.
|
||||||
|
#
|
||||||
|
# scripts/bringup-repo.sh create make it
|
||||||
|
# scripts/bringup-repo.sh reset delete it and make it again
|
||||||
|
# scripts/bringup-repo.sh delete delete it
|
||||||
|
#
|
||||||
|
# Needs NISSE_OPERATOR_TOKEN (.env here) and the bring-up agent's token
|
||||||
|
# (NISSE_GITEA_TOKEN in $E2E/.env), which names the agent's account.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
E2E=${NISSE_E2E:-/root/nisse-e2e}
|
||||||
|
case "${1:-}" in create|reset|delete) ;; *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; esac
|
||||||
|
set -a; [ -f .env ] && . ./.env; set +a
|
||||||
|
exec uv run python - "$1" "$E2E" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, "tests/integration")
|
||||||
|
from e2e_support import OWNER, delete_repo, make_repo, operator_gitea # noqa: E402
|
||||||
|
|
||||||
|
from nisse_dev.envfile import load_env_file # noqa: E402
|
||||||
|
from nisse_dev.gitea import GiteaClient # noqa: E402
|
||||||
|
|
||||||
|
action, e2e = sys.argv[1], Path(sys.argv[2])
|
||||||
|
url = "https://gitea.dongar.net"
|
||||||
|
operator = operator_gitea(url)
|
||||||
|
name = "nisse-bringup"
|
||||||
|
if action in ("reset", "delete"):
|
||||||
|
delete_repo(operator, f"{OWNER}/{name}")
|
||||||
|
print(f"deleted {OWNER}/{name}")
|
||||||
|
if action in ("create", "reset"):
|
||||||
|
agent_env: dict[str, str] = {}
|
||||||
|
load_env_file(e2e / ".env", agent_env)
|
||||||
|
agent = GiteaClient(url, agent_env["NISSE_GITEA_TOKEN"]).whoami()
|
||||||
|
repo = make_repo(operator, name, e2e / "ssh", agent=agent)
|
||||||
|
print(f"made {repo.name}: {agent} a collaborator; push key {repo.push_key}")
|
||||||
|
PY
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The throwaway sshd the tests need (nisse_dev/testing.py), in a CI job.
|
||||||
|
# Skipped when the job's image already has it. Otherwise apt fetches from a
|
||||||
|
# kernel.org mirror rather than archive.ubuntu.com, which has been slow
|
||||||
|
# enough (a few KB/s) to add minutes to every job.
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=$(command -v sudo || true)
|
||||||
|
if command -v /usr/sbin/sshd > /dev/null && command -v ssh > /dev/null; then
|
||||||
|
echo "sshd already installed"
|
||||||
|
else
|
||||||
|
MIRROR=${APT_MIRROR:-http://mirrors.edge.kernel.org/ubuntu}
|
||||||
|
# Both layouts: deb822 (ubuntu.sources, 24.04+) and sources.list (older).
|
||||||
|
for f in /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list; do
|
||||||
|
[ -f "$f" ] && $SUDO sed -i -E \
|
||||||
|
"s#https?://(archive|security)\.ubuntu\.com/ubuntu/?#$MIRROR/#g" "$f"
|
||||||
|
done
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
||||||
|
fi
|
||||||
|
$SUDO mkdir -p /run/sshd
|
||||||
@@ -23,6 +23,8 @@ if [ "${1:-}" = "--image" ]; then
|
|||||||
mkdir -p "$runs"
|
mkdir -p "$runs"
|
||||||
secrets=()
|
secrets=()
|
||||||
[ -f .env ] && secrets=(--env-file .env)
|
[ -f .env ] && secrets=(--env-file .env)
|
||||||
|
# The model, if overridden here, reaches the tests inside too.
|
||||||
|
[ -n "${NISSE_E2E_MODEL:-}" ] && secrets+=(-e "NISSE_E2E_MODEL=$NISSE_E2E_MODEL")
|
||||||
# Host networking: the suite reaches Matrix, Gitea and LiteLLM as here.
|
# Host networking: the suite reaches Matrix, Gitea and LiteLLM as here.
|
||||||
exec docker run --rm --network host "${secrets[@]}" \
|
exec docker run --rm --network host "${secrets[@]}" \
|
||||||
-e NISSE_E2E_RUNS=/runs -v "$runs:/runs" \
|
-e NISSE_E2E_RUNS=/runs -v "$runs:/runs" \
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ from nisse_dev.llm import RoleMap
|
|||||||
|
|
||||||
# The end-to-end tests run every role on a local model, which costs nothing
|
# The end-to-end tests run every role on a local model, which costs nothing
|
||||||
# per call; NISSE_E2E_MODEL overrides it (e.g. claude-haiku-4-5 for a quick,
|
# per call; NISSE_E2E_MODEL overrides it (e.g. claude-haiku-4-5 for a quick,
|
||||||
# paid run).
|
# paid run). The no-thinking alias (LiteLLM: chat_template_kwargs
|
||||||
E2E_MODEL = "qwen3.8-27b"
|
# enable_thinking false): with thinking, some answers ran to 6-8k tokens,
|
||||||
|
# minutes each, and stretched a test from 3.5 to 20 minutes.
|
||||||
|
E2E_MODEL = "qwen3.8-27b-nothink"
|
||||||
|
|
||||||
|
|
||||||
def e2e_roles() -> RoleMap:
|
def e2e_roles() -> RoleMap:
|
||||||
@@ -59,16 +61,14 @@ class FreshRepo:
|
|||||||
push_key: Path # this repository's deploy key, private half
|
push_key: Path # this repository's deploy key, private half
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
def make_repo(
|
||||||
def fresh_repo(
|
operator: GiteaClient, name: str, keys: Path, *, agent: str, seed: bool = True
|
||||||
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
|
) -> FreshRepo:
|
||||||
) -> Iterator[FreshRepo]:
|
"""A repository as the operator would make a project's (D4, D8): seeded
|
||||||
"""A repository of its own for one test run, as the operator would make a
|
with a small Python package (or only a README), the ``agent`` account a
|
||||||
project's (D4, D8): seeded with a small Python package, the ``agent``
|
collaborator with write access, the default branch protected (pushes only
|
||||||
account a collaborator with write access, the default branch protected (pushes only by the
|
by the owner, an approval to merge), and a deploy key of its own, written
|
||||||
owner, an approval to merge), and a deploy key of its own, written under
|
under ``keys``. A failure part-way deletes what was made."""
|
||||||
``keys``. Deleted afterwards, unless NISSE_E2E_KEEP_REPOS is set."""
|
|
||||||
name = f"nisse-e2e-{run}"
|
|
||||||
full = f"{OWNER}/{name}"
|
full = f"{OWNER}/{name}"
|
||||||
operator._request(
|
operator._request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -145,8 +145,35 @@ def fresh_repo(
|
|||||||
"required_approvals": 1,
|
"required_approvals": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
yield FreshRepo(full, key)
|
# The labels the agent reads (D1), as a project would have them.
|
||||||
|
for label, color in (
|
||||||
|
("bug", "#ee0701"),
|
||||||
|
("discuss", "#0e8a16"),
|
||||||
|
("design", "#5319e7"),
|
||||||
|
):
|
||||||
|
operator._request(
|
||||||
|
"POST", f"/repos/{full}/labels", json={"name": label, "color": color}
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
delete_repo(operator, full)
|
||||||
|
raise
|
||||||
|
return FreshRepo(full, key)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_repo(operator: GiteaClient, full: str) -> None:
|
||||||
|
with contextlib.suppress(GiteaError):
|
||||||
|
operator._request("DELETE", f"/repos/{full}")
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def fresh_repo(
|
||||||
|
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
|
||||||
|
) -> Iterator[FreshRepo]:
|
||||||
|
"""A repository of its own for one test run (make_repo), deleted
|
||||||
|
afterwards unless NISSE_E2E_KEEP_REPOS is set."""
|
||||||
|
repo = make_repo(operator, f"nisse-e2e-{run}", keys, agent=agent, seed=seed)
|
||||||
|
try:
|
||||||
|
yield repo
|
||||||
finally:
|
finally:
|
||||||
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
if not os.environ.get("NISSE_E2E_KEEP_REPOS"):
|
||||||
with contextlib.suppress(GiteaError):
|
delete_repo(operator, repo.name)
|
||||||
operator._request("DELETE", f"/repos/{full}")
|
|
||||||
@@ -239,6 +239,7 @@ class Operator:
|
|||||||
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
def test_the_slice_end_to_end(
|
def test_the_slice_end_to_end(
|
||||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -501,6 +502,7 @@ def test_a_two_milestone_feature(
|
|||||||
operator.tidy(issues, prs)
|
operator.tidy(issues, prs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
def test_a_conversation_becomes_filed_work(
|
def test_a_conversation_becomes_filed_work(
|
||||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -636,6 +638,7 @@ def test_a_conversation_becomes_a_new_project(
|
|||||||
assert any(f.startswith("docs/plans/") for f in files), files
|
assert any(f.startswith("docs/plans/") for f in files), files
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
def test_a_question_leaves_the_restatement_up(
|
def test_a_question_leaves_the_restatement_up(
|
||||||
running: tuple[RunningLoop, InMemoryTransport, str],
|
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ members = [
|
|||||||
[manifest.dependency-groups]
|
[manifest.dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest", specifier = ">=8" },
|
{ name = "pytest", specifier = ">=8" },
|
||||||
|
{ name = "pytest-xdist", specifier = ">=3" },
|
||||||
{ name = "ruff", specifier = ">=0.6" },
|
{ name = "ruff", specifier = ">=0.6" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -46,6 +47,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "execnet"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -177,6 +187,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-xdist"
|
||||||
|
version = "3.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "execnet" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|||||||
Reference in new issue
Block a user