feat: edge node registry, opsconsole, CLI persistent, and e2e smoke updates

- Refactor edge node registry and store for improved management
- Update opsconsole console, events, and status components
- Enhance edge service and transport layers
- Improve CLI persistent adapter and output filter
- Update e2e smoke test script
- Update project and testing domain rules
- Update Makefile and documentation
This commit is contained in:
toki 2026-05-19 09:39:58 +09:00
parent 42e0810a26
commit 92042bb2a3
20 changed files with 756 additions and 246 deletions

View file

@ -17,6 +17,7 @@ test:
go test ./...
test-e2e:
@echo "NOTE: test-e2e runs auxiliary smoke only; completion requires bin/edge.sh + bin/node.sh user-flow verification."
./scripts/e2e-smoke.sh
# Requires: protoc + protoc-gen-go (go install google.golang.org/protobuf/cmd/protoc-gen-go@latest)

View file

@ -2,13 +2,13 @@
## 목적 / 책임
작업 완료 후 어떤 테스트와 E2E smoke 검증을 거쳐야 하는지 정리한다. 테스트 파일을 바꿨는지가 아니라, 변경 작업이 어떤 사용자 실행 파이프라인에 닿았는지를 기준으로 검증 범위를 정한다.
작업 완료 후 어떤 테스트와 bin shell 사용자 흐름 검증을 거쳐야 하는지 정리한다. 테스트 파일을 바꿨는지가 아니라, 변경 작업이 어떤 사용자 실행 파이프라인에 닿았는지를 기준으로 검증 범위를 정한다.
## 포함 경로
- `Makefile` — 공식 test target과 향후 E2E smoke target을 선언하는 위치이다.
- `bin/edge.sh` — 사용자가 edge console/server를 실행하는 shell entrypoint이며 E2E smoke의 검증 대상이다.
- `bin/node.sh` — 사용자가 node를 edge에 연결하는 shell entrypoint이며 E2E smoke의 검증 대상이다.
- `Makefile` — 공식 test target과 보조 smoke target을 선언하는 위치이다.
- `bin/edge.sh` — 사용자가 edge console/server를 실행하는 shell entrypoint이며 bin shell 사용자 흐름 검증의 기준 대상이다.
- `bin/node.sh` — 사용자가 node를 edge에 연결하는 shell entrypoint이며 bin shell 사용자 흐름 검증의 기준 대상이다.
## 제외 경로
@ -20,22 +20,72 @@
- 대상 패키지 테스트 — 변경한 패키지와 인접한 패키지의 빠른 회귀 검증이다.
- `go test ./...` — 저장소 전체 Go 테스트 회귀 검증이다.
- `bin/edge.sh` + `bin/node.sh` E2E smoke — 사용자가 실행하는 entrypoint를 통해 edge-node 등록, console 입력, adapter 실행, event/message 출력을 빠르게 확인하는 검증이다.
- bin shell 사용자 흐름 검증 — 사용자가 하듯이 `bin/edge.sh``bin/node.sh`를 각각 실행하고, edge console에서 메시지 2회와 command 명령을 직접 보내 결과가 edge 화면에 도착하는지 확인하는 기준 검증이다.
- 보조 E2E smoke — 임시 설정과 mock adapter로 최소 생존을 빠르게 확인하는 보조 검증이다. 이 결과만으로 완료 처리하지 않는다.
- full-cycle 실제 구동 — 비효율적이어도 관련 사용자 명령과 실행 cycle을 한 번씩 실제 entrypoint로 통과시키는 검증이다.
- 실제 외부 CLI 검증 — `claude`, `gemini`, `codex`, `opencode`처럼 외부 CLI 설치와 계정/환경이 필요한 기준 profile을 실제 호출하는 검증이다.
## 유지할 패턴
- 테스트는 테스트 파일 변경 여부가 아니라 작업 영향 범위로 결정한다.
- 사용자 실행 파이프라인에 닿는 작업을 한 경우, 작업 완료 후 일반 Go 테스트, `bin/edge.sh` + `bin/node.sh` 기반 E2E smoke, full-cycle 실제 구동 흐름을 반드시 검증한다.
- 사용자 실행 파이프라인에 닿는 작업을 한 경우, 작업 완료 후 일반 Go 테스트`bin/edge.sh` + `bin/node.sh` 기반 bin shell 사용자 흐름을 반드시 검증한다. 보조 E2E smoke는 추가로 수행할 수 있지만 완료 기준이 아니다.
- 사용자 실행 파이프라인에는 `bin/**`, `apps/*/cmd/**`, `apps/*/internal/bootstrap/**`, edge-node transport/service/registry, adapter 실행/stream/cancel/status 경로, `configs/**`, `packages/config/**`, 관련 protobuf 계약 변경이 포함된다.
- 기본 E2E smoke는 mock adapter와 임시 설정/포트를 사용해 외부 CLI 의존성 없이 수행한다.
- E2E smoke에서는 최소한 node 등록, `/nodes` 확인, console 메시지 전송, delta/message 출력, complete event를 확인한다.
- bin shell 사용자 흐름 검증은 `bin/edge.sh``bin/node.sh`를 별도 프로세스로 직접 실행하고, edge console prompt에 명령을 한 줄씩 입력한 뒤 기대 출력이 도착한 것을 확인하고 다음 입력으로 넘어간다.
- 메시지 검증 기준은 edge console에서 같은 session으로 메시지 2회를 보내고, 각 요청마다 `[edge] sent`, `[node-*-event] start`, 비어 있지 않은 `[node-*-message]`, `[node-*-event] complete`가 edge 화면에 표시되는 것이다.
- 완료 이벤트만으로 정상 판정하지 않는다. node 로컬 출력에 생성된 `[node-message]` payload가 edge console의 `[node-*-message]` 출력에 모두 표시되어야 하며, complete event는 모든 message payload가 edge에 도착한 뒤의 마감 신호로 본다.
- command 검증 기준은 edge console에서 `/nodes`와 변경 범위에 닿는 command를 직접 입력하고, node에서 온 결과가 edge 화면에 `[node-*-<command>]` 또는 명확한 성공/unsupported/error 출력으로 표시되는 것이다. CLI 경로 변경 시 최소 `/capabilities`, `/transport`, `/sessions`, persistent profile이면 `/terminate-session`을 확인한다.
- 보조 E2E smoke는 mock adapter와 임시 설정/포트를 사용해 외부 CLI 의존성 없이 수행한다.
- 보조 E2E smoke에서는 최소한 node 등록, `/nodes` 확인, console 메시지 전송, delta/message 출력, complete event를 확인한다.
- full-cycle 실제 구동에서는 startup/register, foreground run, session 변경, background run, terminate-session, status, 관련 routing/cancel/timeout/persistent session cycle을 실제 entrypoint로 한 번씩 통과시킨다.
- 상세 수행 절차와 기능별 체크리스트는 `agent-ops/skills/project/e2e-smoke/SKILL.md`를 따른다.
- `make test-e2e` 같은 고정 명령이 생기면 그 명령을 우선 사용한다. 아직 고정 명령이 없으면 동일한 내용을 수동 smoke 절차로 검증하거나, 실행하지 못한 이유를 최종 보고에 명시한다.
- `make test-e2e` 또는 `scripts/e2e-smoke.sh`는 보조 smoke 명령이다. 실행할 수 있으면 보조 확인으로 기록하되, bin shell 사용자 흐름을 대체하지 않는다.
- 풀테스트에서는 실제 외부 CLI profile 검증을 필수로 수행한다. 환경, 계정, provider, 원격 endpoint 문제로 호출할 수 없거나 실패한 profile은 누락하지 말고 profile별 실패 또는 blocker로 보고한다.
- 작업 최종 보고에는 실행한 테스트 명령, E2E smoke 수행 여부, full-cycle 실제 구동 수행 여부를 명시한다. 수행하지 못한 필수 검증은 이유와 남은 위험을 함께 적는다.
- 작업 최종 보고에는 실행한 테스트 명령, bin shell 사용자 흐름 수행 여부, 보조 E2E smoke 수행 여부, full-cycle 실제 구동 수행 여부를 명시한다. 수행하지 못한 필수 검증은 이유와 남은 위험을 함께 적는다.
## 기준 출력 예시
아래처럼 edge console에서 입력한 메시지 2회와 command 결과가 edge 화면에 도착해야 기준을 통과한 것으로 본다. run id와 node alias는 실행 환경에 따라 달라질 수 있다.
```text
edge> /nodes
test-node (test-node)
edge> Convert token iop_manual_one and reply only with converted token
[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false
[node-test-node-event] start run_id=manual-...
[node-test-node-message] IOP_MANUAL_ONE_OK
[node-test-node-message] IOP_MANUAL_ONE_TAIL
[node-test-node-event] complete run_id=manual-... detail="idle-timeout"
edge> Convert token iop_manual_two and reply only with converted token
[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false
[node-test-node-event] start run_id=manual-...
[node-test-node-message] IOP_MANUAL_TWO_OK
[node-test-node-message] IOP_MANUAL_TWO_TAIL
[node-test-node-event] complete run_id=manual-... detail="idle-timeout"
edge> /capabilities
[node-test-node-capabilities] target=fake-cli session=default
adapter = cli
max_concurrency = 4
targets = fake-cli
edge> /transport
[node-test-node-transport] target=fake-cli session=default
adapter = cli
connected = true
node_id = test-node
session_id = default
target = fake-cli
edge> /sessions
[node-test-node-sessions] target=fake-cli session=default
count = 1
sessions = persistent:fake-cli/default
edge> /terminate-session
terminated session default node=test-node
```
## 다른 도메인과의 경계
@ -46,7 +96,8 @@
## 금지 사항
- 사용자 실행 파이프라인에 닿는 변경을 하고 유닛/패키지 테스트만으로 완료 처리하지 않는다.
- `make test-e2e`, `scripts/e2e-smoke.sh`, 또는 smoke 통과 출력만으로 완료 처리하지 않는다.
- 관련 작업 후 full-cycle 실제 구동을 비용이 크다는 이유만으로 생략하지 않는다.
- 기본 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다.
- E2E smoke를 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. 임시 설정 파일이나 환경 변수 override를 사용한다.
- 보조 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다.
- 검증을 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다. 임시 설정 파일이나 환경 변수 override를 사용한다.
- 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다.

View file

@ -76,5 +76,5 @@
## 스킬 라우팅
- 사용자 실행 파이프라인 검증, E2E smoke, full-cycle 실제 구동, `bin/edge.sh`/`bin/node.sh` 통합 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md`
- 사용자 실행 파이프라인 검증, bin shell 사용자 흐름, 메시지 2회 왕복, edge command 응답, 보조 E2E smoke, full-cycle 실제 구동, `bin/edge.sh`/`bin/node.sh` 통합 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md`
- 반복 작업이 확인되면 `agent-ops/skills/project/<skill-name>/SKILL.md`를 생성하고 이 표에 등록한다.

View file

@ -1,115 +1,143 @@
---
name: e2e-smoke
version: 1.0.0
description: bin/edge.sh와 bin/node.sh 기반 사용자 실행 파이프라인 E2E smoke, full-cycle 실제 구동, 실제 외부 CLI 통합 검증 절차
version: 1.1.0
description: bin/edge.sh와 bin/node.sh 기반 bin shell 사용자 흐름 검증, 보조 E2E smoke, 실제 외부 CLI 통합 검증 절차
---
# e2e-smoke
## 목적
사용자 실행 방식에 가까운 `bin/edge.sh` + `bin/node.sh` 경로로 edge-node 실행 파이프라인을 검증한다. 빠른 mock smoke로 최소 생존을 확인하고, 관련 작업 후에는 비효율적이어도 실제 구동 full-cycle과 실제 외부 CLI profile 호출을 한 번씩 통과시킨다. 실제 외부 CLI profile 검증은 풀테스트의 필수 항목이며, 불가능하거나 실패하면 완료로 숨기지 않고 profile별 blocker 또는 실패로 명시한다.
사용자가 실제로 테스트하는 방식에 맞춰 `bin/edge.sh``bin/node.sh`를 각각 실행하고, edge console에서 메시지 2회와 command 명령을 직접 보내 결과가 edge 화면에 표시되는지 검증한다. `make test-e2e``scripts/e2e-smoke.sh`는 최소 생존을 빠르게 확인하는 보조 smoke일 뿐이며, 사용자 실행 파이프라인 변경의 완료 기준이 아니다.
## 언제 호출할지
- 사용자 실행 파이프라인에 닿는 작업을 완료한 뒤 검증 단계에 들어갈 때
- `bin/**`, `apps/*/cmd/**`, `apps/*/internal/bootstrap/**`, edge-node transport/service/registry, adapter 실행/stream/cancel/status 경로를 변경했을 때
- `configs/**`, `packages/config/**`, 관련 protobuf 계약 변경이 edge-node 실행 방식에 영향을 줄 때
- `make test-e2e` 또는 E2E smoke 스크립트를 만들거나 갱신할 때
- `make test-e2e` 또는 보조 E2E smoke 스크립트를 만들거나 갱신할 때
## 입력
- `scope`: 검증할 변경 범위와 영향을 받은 도메인 (필수)
- `mode`: `mock-smoke`, `full-cycle`, `real-cli`, `all` 중 하나 (선택, 기본값: `full-cycle`)
- `mode`: `bin-user-flow`, `mock-smoke`, `real-cli`, `full-cycle`, `all` 중 하나 (선택, 기본값: `bin-user-flow`)
- `profiles`: 검증할 실제 외부 CLI profile 목록. 지정하지 않으면 기준 profile인 `claude`, `gemini`, `codex`, `opencode`를 대상으로 한다. (선택)
- `config_strategy`: 임시 config 파일 또는 환경 변수 override 방식 (선택)
## 먼저 확인할 것
- [ ] `agent-ops/rules/project/domain/testing/rules.md`를 읽고 이번 작업이 필수 E2E smoke 대상인지 확인한다.
- [ ] `agent-ops/rules/project/domain/testing/rules.md`를 읽고 이번 작업이 필수 bin shell 사용자 흐름 검증 대상인지 확인한다.
- [ ] `bin/edge.sh``bin/node.sh`가 현재 사용자 실행 entrypoint인지 확인한다.
- [ ] `make test-e2e` 같은 고정 명령이 이미 있으면 그 명령을 우선 사용한다.
- [ ] 고정 명령이 없으면 임시 config와 랜덤 포트로 수동 smoke 절차를 구성한다.
- [ ] 관련 작업 후에는 빠른 smoke만으로 완료 처리하지 말고 full-cycle 실제 구동 항목을 한 번씩 수행할 수 있는지 확인한다.
- [ ] 실제 외부 CLI command 경로, 로그인/계정 상태, provider 설정, workspace 권한이 준비되어 있는지 확인한다.
- [ ] `make test-e2e` 같은 고정 smoke 명령은 보조 확인으로만 취급한다.
- [ ] 기본 `configs/*.yaml`을 오염시키지 않도록 임시 config 또는 환경 변수 override 전략을 정한다.
- [ ] 실제 외부 CLI 검증이 필요한 경우 command 경로, 로그인/계정 상태, provider 설정, workspace 권한이 준비되어 있는지 확인한다.
## 실행 절차
1. **검증 범위 결정**
- 변경 파일이 사용자 실행 파이프라인에 닿는지 확인한다.
- 필수 검증은 `go test ./...` 또는 대상 패키지 테스트, mock smoke, full-cycle 실제 구동이다.
- 필수 검증은 `go test ./...` 또는 대상 패키지 테스트와 bin shell 사용자 흐름 검증이다.
- 보조 smoke는 실행할 수 있으면 기록하되, 통과만으로 완료 처리하지 않는다.
- 풀테스트에서는 실제 외부 CLI profile 검증을 full-cycle에 포함한다.
- real CLI 환경이 준비되지 않았거나 호출이 실패하면 완료로 숨기지 말고 profile별 blocker 또는 실패로 보고한다.
2. **기본 E2E smoke 준비**
- 기본 `configs/*.yaml`을 오염시키지 말고 임시 edge/node config 또는 환경 변수 override를 사용한다.
- edge listen 주소와 node edge 주소는 충돌을 피하기 위해 임시 포트를 사용한다.
- edge console target은 외부 CLI가 아닌 mock adapter target으로 구성한다.
2. **bin shell 사용자 흐름 준비**
- edge와 node는 `bin/edge.sh`, `bin/node.sh`를 각각 별도 프로세스로 실행한다.
- 임시 edge/node config 또는 환경 변수 override를 사용하고, edge listen 주소와 node edge 주소는 충돌을 피하기 위해 임시 포트를 사용한다.
- 검증용 target은 변경 범위에 맞춘다. 외부 CLI 자체가 검증 대상이 아니면 deterministic CLI profile을 사용해 메시지 출력 내용을 분명하게 만든다.
3. **기본 E2E smoke 실행**
- `bin/edge.sh``bin/node.sh`를 실제 entrypoint로 실행한다.
- node가 edge에 register되고 console `/nodes` 출력에 node ID와 alias가 표시되는지 확인한다.
- mock adapter 대상으로 같은 session에서 메시지를 최소 2회 전송한다.
- 두 요청 모두 `[edge] sent`, `[node-*-event] start`, `[node-*-message]`, `[node-*-event] complete` 출력이 생기는지 확인한다.
3. **startup/register cycle**
- 먼저 `bin/edge.sh`를 실행해 edge console prompt가 뜨는지 확인한다.
- 다른 프로세스에서 `bin/node.sh`를 실행해 node가 edge에 연결되는지 확인한다.
- edge 화면에 `[node-*-event] connected reason="registered"`가 표시되어야 한다.
- edge console에서 `/nodes`를 입력하고 node ID와 alias가 표시되어야 한다.
4. **메시지 2회 왕복 기준**
- edge console prompt에 메시지를 한 번 입력한다.
- 첫 번째 요청에서 `[edge] sent`, `[node-*-event] start`, 비어 있지 않은 `[node-*-message]`, `[node-*-event] complete`가 edge 화면에 도착해야 한다.
- node 로컬 출력에 생성된 `[node-message]` payload가 edge console의 `[node-*-message]` 출력에 모두 표시되었는지 비교한다.
- complete event만으로 정상 판정하지 않는다. complete event는 node가 생성한 모든 message payload가 edge에 표시된 뒤의 마감 신호여야 한다.
- 같은 session에서 두 번째 메시지를 입력하고 같은 출력 흐름이 다시 도착해야 한다.
- 두 번째 메시지가 첫 번째 메시지 이후에도 같은 edge-node 연결에서 정상 처리되는지 확인한다.
- background off 기본값에서 foreground 응답 대기가 complete event까지 이어지는지 확인한다.
- `/session <id>` 변경 후 메시지를 보내고 출력의 session 값과 node event/message 흐름이 유지되는지 확인한다.
- `/terminate-session` 명령이 성공 출력 또는 mock adapter의 명확한 unsupported/error 출력을 내는지 확인한다.
- foreground mock smoke에서는 `<empty>` message line이 없어야 하고, foreground complete 출력 수가 전송한 run 수를 초과하지 않는지 확인한다.
- foreground run에서는 complete event 전에 다음 검증으로 넘어가지 않는다.
4. **full-cycle 실제 구동**
- smoke와 같은 임시 config/포트 방식으로 실제 `bin/edge.sh``bin/node.sh`를 유지한 채 아래 사용자 명령 사이클을 한 번씩 통과시킨다.
- 명령을 한꺼번에 stdin에 밀어 넣지 말고, 각 cycle의 기대 출력이 로그에 나타난 뒤 다음 명령을 보낸다. 특히 background run은 async start/message/complete를 확인한 뒤 `/background off``quit`로 넘어간다.
- startup/register cycle: edge 기동, node edge 대기, register, `/nodes`, node disconnect lifecycle 출력까지 확인한다.
- foreground run cycle: 같은 session에서 메시지를 최소 2회 전송하고 각 run의 sent/start/message/complete를 확인한다.
- session cycle: `/session <id>`로 session을 바꾼 뒤 메시지를 보내고 출력의 session 값과 run 흐름을 확인한다.
- background cycle: `/background on` 후 메시지를 보내 async start/message/complete가 출력되는지 확인하고, `/background off`로 되돌린다.
- terminate cycle: `/terminate-session`을 실행하고 성공 출력 또는 명확한 unsupported/error 출력을 확인한다.
- status cycle: 현재 target에서 `/status`를 실행한다. 지원 profile이면 status 출력 내용을 확인하고, 미지원 profile이면 명확한 unsupported error를 확인한다.
- multi-node/routing을 바꾼 작업에서는 node 2대를 실제로 붙여 ambiguous error와 `/node <id|alias>` 선택 후 라우팅을 한 번씩 확인한다.
- cancel/timeout/persistent session을 바꾼 작업에서는 해당 cycle도 실제 구동으로 한 번씩 확인한다.
5. **edge command 응답 기준**
- edge console에서 command를 직접 입력하고 결과가 edge 화면에 도착하는지 확인한다.
- 기본 command 기준은 `/nodes`, `/capabilities`, `/transport`, `/sessions`이다.
- persistent profile이면 `/terminate-session`을 실행하고 성공 출력 또는 명확한 unsupported/error 출력을 확인한다.
- status 경로를 바꾼 작업은 `/status` 결과를 확인한다. 지원 profile이면 status 출력 내용을 확인하고, 미지원 profile이면 명확한 unsupported error를 확인한다.
- multi-node/routing을 바꾼 작업은 node 2대를 실제로 붙여 ambiguous error와 `/node <id|alias>` 선택 후 라우팅을 확인한다.
- cancel/timeout/persistent session을 바꾼 작업은 해당 cycle도 실제 구동으로 확인한다.
5. **edge console / transport 항목 확인**
- node 연결/해제 lifecycle event가 run event와 분리되어 출력되는지 확인한다.
- `RunEvent``start`, `delta`, `complete`, `error`, `cancelled`가 console 출력 형식에 맞게 라우팅되는지 확인한다.
- node가 1대일 때는 node 선택 없이 single-node fallback으로 실행되는지 확인한다.
- node가 2대 이상인 smoke를 수행한다면 `/node <id|alias>` 선택 없이는 ambiguous error가 출력되는지 확인한다.
- `/node <id|alias>` 선택 후 선택된 node로 run request가 전달되고 출력 prefix가 해당 node label을 사용하는지 확인한다.
6. **기준 출력 예시**
6. **CLI profile 실제 구동 검증**
```text
edge> /nodes
test-node (test-node)
edge> Convert token iop_manual_one and reply only with converted token
[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false
[node-test-node-event] start run_id=manual-...
[node-test-node-message] IOP_MANUAL_ONE_OK
[node-test-node-message] IOP_MANUAL_ONE_TAIL
[node-test-node-event] complete run_id=manual-... detail="idle-timeout"
edge> Convert token iop_manual_two and reply only with converted token
[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false
[node-test-node-event] start run_id=manual-...
[node-test-node-message] IOP_MANUAL_TWO_OK
[node-test-node-message] IOP_MANUAL_TWO_TAIL
[node-test-node-event] complete run_id=manual-... detail="idle-timeout"
edge> /capabilities
[node-test-node-capabilities] target=fake-cli session=default
adapter = cli
max_concurrency = 4
targets = fake-cli
edge> /transport
[node-test-node-transport] target=fake-cli session=default
adapter = cli
connected = true
node_id = test-node
session_id = default
target = fake-cli
edge> /sessions
[node-test-node-sessions] target=fake-cli session=default
count = 1
sessions = persistent:fake-cli/default
edge> /terminate-session
terminated session default node=test-node
```
7. **보조 E2E smoke**
- `make test-e2e` 또는 `scripts/e2e-smoke.sh`는 보조 확인으로 실행할 수 있다.
- 보조 smoke에서는 mock adapter와 임시 설정/포트를 사용해 외부 CLI 의존성 없이 최소 생존을 확인한다.
- 보조 smoke 통과를 bin shell 사용자 흐름 통과로 대체해 보고하지 않는다.
8. **CLI profile 실제 구동 검증**
- 풀테스트에서는 기준 실제 외부 CLI profile을 한 번씩 실제 구동한다.
- `claude`, `gemini`, `codex`, `opencode`를 대상으로 같은 session 메시지를 최소 2회 주고받는다.
- 각 profile에서 start, delta-or-message, complete 출력이 모두 생기는지 확인한다.
- `opencode`는 로컬 모델 대표 profile로 보고 SSE 기반 delta/message와 complete 출력이 생기는지 확인한다.
- command, 로그인, provider, workspace 권한, 원격 endpoint 문제로 실행할 수 없거나 실패한 profile은 누락이 아니라 실패/blocker로 profile별 보고한다.
7. **usage status 실제 구동 검증**
- `/status``codex`, `claude`, `gemini` 사용량을 조회한다.
- `[edge] sent command=status``[node-*-status]`가 출력되는지 확인한다.
- `codex``claude`는 daily/weekly limit 또는 raw fallback 출력이 표시되는지 확인한다.
- `gemini`는 quota metadata 또는 model usage 출력이 표시되는지 확인한다.
- `opencode`처럼 status checker가 아직 없는 profile은 조용히 실패하지 않고 명확한 unsupported error를 출력하는지 확인한다.
- parsed limit이 있으면 label/reset 정보를 포함하고, parsed limit이 없으면 raw output 일부를 보여주는지 확인한다.
8. **cancel / session lifecycle 검증**
- 변경 범위가 cancel, timeout, persistent session에 닿는 경우 수행한다.
- foreground run 중 cancel 요청이 전달되면 cancelled event가 출력되고 node store 상태가 cancelled로 마감되는지 확인한다.
- persistent CLI session에서 `/terminate-session` 후 같은 session 재사용 시 새 worker 생성 또는 `REQUIRE_EXISTING` 실패가 의도대로 동작하는지 확인한다.
- timeout 발생 시 timeout message와 error/cancelled 상태가 사용자 출력에 드러나는지 확인한다.
9. **결과 보고**
- 실행한 Go 테스트 명령과 결과를 적는다.
- 실행한 E2E smoke와 full-cycle 실제 구동 방식, 사용한 config 전략, 검증한 profile을 적는다.
- bin shell 사용자 흐름에서 사용한 config 전략, 메시지 2회 결과, command 응답 결과를 적는다.
- 보조 smoke를 실행했다면 별도 항목으로만 적는다.
- full-cycle에서 돌린 cycle 목록과 돌리지 못한 cycle 목록을 구분해 적는다.
- 필수 검증을 실행하지 못했다면 이유와 남은 위험을 명시한다.
## 실행 결과 검증
- [ ] 일반 Go 테스트 또는 대상 패키지 테스트 결과가 보고되었는가
- [ ] 사용자 실행 파이프라인 변경 시 mock E2E smoke 수행 여부가 보고되었는가
- [ ] 사용자 실행 파이프라인 변경 시 full-cycle 실제 구동 항목을 한 번씩 수행했는가
- [ ] node register, `/nodes`, 메시지 최소 2회, event/message/complete 출력 확인 여부가 보고되었는가
- [ ] session, background, terminate, status 등 관련 사용자 명령 cycle 수행 여부가 보고되었는가
- [ ] `bin/edge.sh``bin/node.sh`를 각각 실행한 bin shell 사용자 흐름 결과가 보고되었는가
- [ ] node register와 `/nodes` 결과가 edge 화면에 표시되었는가
- [ ] 같은 session에서 메시지 2회가 각각 sent/start/message/complete로 표시되었는가
- [ ] node 로컬 `[node-message]` payload가 edge `[node-*-message]` 출력에 모두 표시되었는가
- [ ] `/capabilities`, `/transport`, `/sessions`, 관련 command 결과가 edge 화면에 표시되었는가
- [ ] 보조 smoke를 실행했다면 bin shell 사용자 흐름과 구분해 보고했는가
- [ ] 풀테스트에서 실제 외부 CLI profile별 메시지 왕복과 `/status` 결과가 보고되었는가
- 검증 실패 시: 실패한 항목, 관련 로그/출력 요약, 남은 위험을 최종 보고에 포함한다.
@ -117,10 +145,13 @@ description: bin/edge.sh와 bin/node.sh 기반 사용자 실행 파이프라인
```text
검증 결과
- Go 테스트: <명령><통과|실패|미실행 사유>
- E2E smoke: <mock|real-cli|both><통과|실패|미실행 사유>
- Go 테스트: <명령> - <통과|실패|미실행 사유>
- Bin shell 사용자 흐름: <통과|실패|미실행 사유>
- 메시지 왕복: message x2 - <통과|실패|미실행 사유>
- Node message relay: node local payload == edge rendered payload - <통과|실패|미실행 사유>
- Edge command 응답: /nodes, /capabilities, /transport, /sessions, <관련 command> - <통과|실패|미실행 사유>
- 보조 E2E smoke: <mock|real-cli|미실행> - <통과|실패|미실행 사유>
- Full-cycle 실제 구동: <통과|실패|미실행 사유>
- 확인 항목: node register, /nodes, message x2, session, background, terminate, status, event/message/complete, <추가 항목>
- Real CLI profile: <profile별 통과|실패|blocker>
- 남은 위험: <없음 또는 내용>
```
@ -128,7 +159,8 @@ description: bin/edge.sh와 bin/node.sh 기반 사용자 실행 파이프라인
## 금지 사항
- 사용자 실행 파이프라인에 닿는 변경을 하고 유닛/패키지 테스트만으로 완료 처리하지 않는다.
- 관련 작업 후 full-cycle 실제 구동을 smoke보다 비용이 크다는 이유만으로 생략하지 않는다.
- 기본 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다.
- E2E smoke를 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다.
- `make test-e2e`, `scripts/e2e-smoke.sh`, 또는 smoke 통과 출력만으로 완료 처리하지 않는다.
- 관련 작업 후 bin shell 사용자 흐름을 비용이 크다는 이유만으로 생략하지 않는다.
- 보조 E2E smoke를 외부 CLI 설치, 로그인, 네트워크 계정 상태에 의존하게 만들지 않는다.
- 검증을 위해 기본 `configs/*.yaml`을 임시값으로 오염시키지 않는다.
- 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다.

View file

@ -23,10 +23,11 @@ ops console은 edge-local diagnostic surface이고, HTTP/API는 central/remote m
실행 요청과 이벤트는 같은 TCP 연결을 공유하지만, 메시지 타입과 내부 파이프라인은 분리한다. `RunRequest`, `CancelRequest`, `NodeCommandRequest`는 edge가 node에 보내는 command/request 계열이고, `RunEvent`, `EdgeNodeEvent`는 node와 edge 내부에서 발생한 event 계열이다.
## 원격 CLI adapter E2E Smoke & 수동 테스트
## 원격 CLI adapter bin shell 사용자 흐름 검증
`make test-e2e` 명령은 `scripts/e2e-smoke.sh`를 실행하여 아래의 테스트 흐름을 임시 설정과 `mock` 프로필로 자동 검증한다.
실제 외부 프로필(예: `gemini`)을 테스트하려면 `IOP_E2E_PROFILE=gemini make test-e2e`와 같이 환경 변수를 지정한다.
사용자 실행 파이프라인 검증의 기준은 `make test-e2e`나 smoke 통과가 아니라, 사람이 하듯이 `bin/edge.sh``bin/node.sh`를 각각 실행하고 edge console에서 메시지와 command를 직접 보내 결과가 edge 화면에 표시되는지 확인하는 것이다.
`make test-e2e` 명령은 `scripts/e2e-smoke.sh`를 실행하는 보조 smoke이며, 최소 생존 확인에는 쓸 수 있지만 완료 기준을 대체하지 않는다.
실제 외부 프로필(예: `gemini`)을 보조 smoke로 테스트하려면 `IOP_E2E_PROFILE=gemini make test-e2e`와 같이 환경 변수를 지정한다.
수동으로 실행하려면 아래 과정을 따른다.
@ -48,17 +49,53 @@ ops console은 edge-local diagnostic surface이고, HTTP/API는 central/remote m
6. edge 콘솔에서 `/nodes`로 node 등록을 확인한다.
7. (선택 사항) node가 여러 대일 경우 `/node <id|alias>`로 대상을 선택한다. 1대일 경우 자동 선택된다.
8. edge 콘솔의 `edge>` 프롬프트에 메시지를 입력한다.
9. `[node-{alias}-event]` 라인으로 실행 상태를, `[node-{alias}-message]` 라인으로 adapter output을 확인한다.
10. edge 콘솔에서 `/exit` 또는 `quit`로 종료한다.
9. 각 메시지마다 `[edge] sent`, `[node{index}-evt] start`, 비어 있지 않은 `[node{index}-msg]`, `[node{index}-evt] complete`가 edge 화면에 표시되는지 확인한다.
10. node 로컬 출력에 생성된 `[node-message]` payload가 edge console의 `[node{index}-msg]` 출력에 모두 표시되었는지 확인한다. complete event만으로 정상 판정하지 않는다.
11. 같은 session에서 두 번째 메시지를 보내 같은 흐름이 다시 표시되는지 확인한다.
12. edge 콘솔에서 `/capabilities`, `/transport`, `/sessions` 등 command를 입력하고 `[node-{alias}-<command>]` 결과가 edge 화면에 도착하는지 확인한다. persistent profile이면 `/terminate-session`도 확인한다.
13. edge 콘솔에서 `/exit` 또는 `quit`로 종료한다.
예상 출력:
기준 출력 예시:
```text
edge> hello
[edge] sent run_id=manual-... node=local-node adapter=cli target=opencode session=default background=false
[node-local-node-event] start run_id=manual-...
[node-local-node-event] complete run_id=manual-... detail="opencode sse execution complete"
[node-local-node-message] <adapter output>
edge> /nodes
test-node (test-node)
edge> Convert token iop_manual_one and reply only with converted token
[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false
[node0-evt] start run_id=manual-...
[node0-msg] IOP_MANUAL_ONE_OK
[node0-msg] IOP_MANUAL_ONE_TAIL
[node0-evt] complete run_id=manual-... detail="idle-timeout"
edge> Convert token iop_manual_two and reply only with converted token
[edge] sent run_id=manual-... node=test-node adapter=cli target=fake-cli session=default background=false
[node0-evt] start run_id=manual-...
[node0-msg] IOP_MANUAL_TWO_OK
[node0-msg] IOP_MANUAL_TWO_TAIL
[node0-evt] complete run_id=manual-... detail="idle-timeout"
edge> /capabilities
[node0-capabilities] target=fake-cli session=default
adapter = cli
max_concurrency = 4
targets = fake-cli
edge> /transport
[node0-transport] target=fake-cli session=default
adapter = cli
connected = true
node_id = test-node
session_id = default
target = fake-cli
edge> /sessions
[node0-sessions] target=fake-cli session=default
count = 1
sessions = persistent:fake-cli/default
edge> /terminate-session
terminated session default node=test-node
```
## Console 명령

View file

@ -9,34 +9,68 @@ import (
// NodeEntry represents one connected node.
type NodeEntry struct {
NodeID string
Alias string
Client *toki.TcpClient
NodeID string
Alias string
Client *toki.TcpClient
Index int
HasIndex bool
}
// Registry manages all nodes connected to edge.
type Registry struct {
mu sync.RWMutex
byID map[string]*NodeEntry
byAlias map[string]*NodeEntry
mu sync.RWMutex
byID map[string]*NodeEntry
byAlias map[string]*NodeEntry
indexByID map[string]int
nextIndex int
}
func NewRegistry() *Registry {
return &Registry{
byID: make(map[string]*NodeEntry),
byAlias: make(map[string]*NodeEntry),
byID: make(map[string]*NodeEntry),
byAlias: make(map[string]*NodeEntry),
indexByID: make(map[string]int),
}
}
func (r *Registry) Register(entry *NodeEntry) {
r.mu.Lock()
defer r.mu.Unlock()
if idx, ok := r.indexByID[entry.NodeID]; ok {
entry.Index = idx
entry.HasIndex = true
} else {
if !entry.HasIndex {
entry.Index = r.nextIndex
}
entry.HasIndex = true
r.indexByID[entry.NodeID] = entry.Index
if entry.Index >= r.nextIndex {
r.nextIndex = entry.Index + 1
}
}
r.byID[entry.NodeID] = entry
if entry.Alias != "" {
r.byAlias[entry.Alias] = entry
}
}
func (e *NodeEntry) DisplayLabel() string {
if e == nil {
return "unknown"
}
if e.HasIndex {
return fmt.Sprintf("node%d", e.Index)
}
if e.Alias != "" {
return e.Alias
}
if e.NodeID != "" {
return e.NodeID
}
return "unknown"
}
func (r *Registry) Unregister(nodeID string) {
r.mu.Lock()
defer r.mu.Unlock()

View file

@ -13,6 +13,7 @@ type NodeRecord struct {
ID string
Alias string
Token string
Index int
Adapters config.AdaptersConf
Runtime config.RuntimeConf
}
@ -93,6 +94,7 @@ func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
ID: nodeID,
Alias: d.Alias,
Token: d.Token,
Index: i,
Adapters: d.Adapters,
Runtime: d.Runtime,
})

View file

@ -274,28 +274,28 @@ func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRou
}
label := events.NodeLabel(event)
if response == nil {
response = NewResponseStream(out, fmt.Sprintf("[node-%s-message] ", label))
response = NewResponseStream(out, nodeMessagePrefix(label))
}
switch event.GetType() {
case "start":
fmt.Fprintf(out, "[node-%s-event] start run_id=%s\n", label, handle.RunID)
fmt.Fprintf(out, "%s start run_id=%s\n", nodeEventPrefix(label), handle.RunID)
case "delta":
response.Write(event.GetDelta())
case "complete":
response.Finish()
fmt.Fprintf(out, "[node-%s-event] complete run_id=%s detail=%q\n", label, handle.RunID, event.GetMessage())
fmt.Fprintf(out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetMessage())
return nil
case "cancelled":
response.FinishIfStarted()
fmt.Fprintf(out, "[node-%s-event] cancelled run_id=%s\n", label, handle.RunID)
fmt.Fprintf(out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), handle.RunID)
return nil
case "error":
response.FinishIfStarted()
fmt.Fprintf(out, "[node-%s-event] error run_id=%s detail=%q\n", label, handle.RunID, event.GetError())
fmt.Fprintf(out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetError())
return fmt.Errorf("node reported error")
default:
fmt.Fprintf(out, "[node-%s-event] %s run_id=%s detail=%q\n", label, event.GetType(), handle.RunID, event.GetMessage())
fmt.Fprintf(out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), handle.RunID, event.GetMessage())
}
}
}

View file

@ -170,7 +170,7 @@ func TestSendRun_SubmitRunRequest_MetadataSource(t *testing.T) {
func TestFormatNodeCommandView_RendersSortedResult(t *testing.T) {
view := edgeservice.NodeCommandView{
NodeLabel: "alpha",
NodeLabel: "node0",
Target: "codex",
SessionID: "default",
Result: map[string]string{
@ -181,7 +181,7 @@ func TestFormatNodeCommandView_RendersSortedResult(t *testing.T) {
var out bytes.Buffer
FormatNodeCommandView(&out, "capabilities", view)
got := out.String()
if !strings.Contains(got, "[node-alpha-capabilities] target=codex session=default") {
if !strings.Contains(got, "[node0-capabilities] target=codex session=default") {
t.Errorf("missing header: %q", got)
}
// keys must appear in sorted order: targets before version

View file

@ -95,35 +95,45 @@ func (r *EventRouter) Drain(ctx context.Context, runEvents <-chan *iop.RunEvent,
}
func (r *EventRouter) NodeLabel(event *iop.RunEvent) string {
nodeID := event.GetNodeId()
if r.registry != nil {
if entry, ok := r.registry.Get(nodeID); ok {
return entry.DisplayLabel()
}
if alias := event.GetNodeAlias(); alias != "" {
if entry, err := r.registry.Resolve(alias); err == nil {
return entry.DisplayLabel()
}
}
}
if alias := event.GetNodeAlias(); alias != "" {
return alias
}
nodeID := event.GetNodeId()
if nodeID == "" {
return "unknown"
if nodeID != "" {
return nodeID
}
if r.registry != nil {
if entry, ok := r.registry.Get(nodeID); ok && entry.Alias != "" {
return entry.Alias
}
}
return nodeID
return "unknown"
}
func (r *EventRouter) edgeNodeLabel(event *iop.EdgeNodeEvent) string {
if event.GetAlias() != "" {
return event.GetAlias()
}
nodeID := event.GetNodeId()
if nodeID == "" {
return "unknown"
}
if r.registry != nil {
if entry, ok := r.registry.Get(nodeID); ok && entry.Alias != "" {
return entry.Alias
if entry, ok := r.registry.Get(nodeID); ok {
return entry.DisplayLabel()
}
if alias := event.GetAlias(); alias != "" {
if entry, err := r.registry.Resolve(alias); err == nil {
return entry.DisplayLabel()
}
}
}
return nodeID
if alias := event.GetAlias(); alias != "" {
return alias
}
if nodeID != "" {
return nodeID
}
return "unknown"
}
func (r *EventRouter) printNodeEventLocked(event *iop.EdgeNodeEvent) {
@ -135,11 +145,11 @@ func (r *EventRouter) printNodeEventLocked(event *iop.EdgeNodeEvent) {
detail := transportCloseDetail(event.GetMetadata())
switch event.GetType() {
case eventpkg.TypeNodeConnected:
fmt.Fprintf(r.out, "[node-%s-event] connected reason=%q%s\n", label, event.GetReason(), detail)
fmt.Fprintf(r.out, "%s connected reason=%q%s\n", nodeEventPrefix(label), event.GetReason(), detail)
case eventpkg.TypeNodeDisconnected:
fmt.Fprintf(r.out, "[node-%s-event] disconnected reason=%q%s\n", label, event.GetReason(), detail)
fmt.Fprintf(r.out, "%s disconnected reason=%q%s\n", nodeEventPrefix(label), event.GetReason(), detail)
default:
fmt.Fprintf(r.out, "[node-%s-event] %s reason=%q%s\n", label, event.GetType(), event.GetReason(), detail)
fmt.Fprintf(r.out, "%s %s reason=%q%s\n", nodeEventPrefix(label), event.GetType(), event.GetReason(), detail)
}
}
@ -166,20 +176,20 @@ func (r *EventRouter) printAsyncLocked(event *iop.RunEvent) {
label := r.NodeLabel(event)
switch event.GetType() {
case "start":
fmt.Fprintf(r.out, "[node-%s-event] start run_id=%s session=%s background=%v\n", label, runID, event.GetSessionId(), event.GetBackground())
fmt.Fprintf(r.out, "%s start run_id=%s session=%s background=%v\n", nodeEventPrefix(label), runID, event.GetSessionId(), event.GetBackground())
case "delta":
r.responseStream(runID, label).Write(event.GetDelta())
case "complete":
r.finishStream(runID, label)
fmt.Fprintf(r.out, "[node-%s-event] complete run_id=%s detail=%q\n", label, runID, event.GetMessage())
fmt.Fprintf(r.out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), runID, event.GetMessage())
case "cancelled":
r.finishStreamIfStarted(runID)
fmt.Fprintf(r.out, "[node-%s-event] cancelled run_id=%s\n", label, runID)
fmt.Fprintf(r.out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), runID)
case "error":
r.finishStreamIfStarted(runID)
fmt.Fprintf(r.out, "[node-%s-event] error run_id=%s detail=%q\n", label, runID, event.GetError())
fmt.Fprintf(r.out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), runID, event.GetError())
default:
fmt.Fprintf(r.out, "[node-%s-event] %s run_id=%s detail=%q\n", label, event.GetType(), runID, event.GetMessage())
fmt.Fprintf(r.out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), runID, event.GetMessage())
}
}
@ -187,7 +197,7 @@ func (r *EventRouter) responseStream(runID, label string) *ResponseStream {
if s, ok := r.streams[runID]; ok {
return s
}
s := NewResponseStream(r.out, fmt.Sprintf("[node-%s-message] ", label))
s := NewResponseStream(r.out, nodeMessagePrefix(label))
r.streams[runID] = s
return s
}
@ -198,7 +208,7 @@ func (r *EventRouter) finishStream(runID, label string) {
delete(r.streams, runID)
return
}
NewResponseStream(r.out, fmt.Sprintf("[node-%s-message] ", label)).Finish()
NewResponseStream(r.out, nodeMessagePrefix(label)).Finish()
}
func (r *EventRouter) finishStreamIfStarted(runID string) {
@ -219,6 +229,14 @@ func NewResponseStream(out io.Writer, prefix string) *ResponseStream {
return &ResponseStream{out: out, prefix: prefix, endedWithNewline: true}
}
func nodeEventPrefix(label string) string {
return fmt.Sprintf("[%s-evt]", label)
}
func nodeMessagePrefix(label string) string {
return fmt.Sprintf("[%s-msg] ", label)
}
func (s *ResponseStream) Write(delta string) {
if s.out == nil || delta == "" {
return

View file

@ -45,7 +45,7 @@ func TestEventRouterPrintsUnregisteredRun(t *testing.T) {
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "start", SessionId: "s1", Background: true})
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: "hello", Background: true})
if got := out.String(); !strings.Contains(got, "[node-unknown-message] hello") {
if got := out.String(); !strings.Contains(got, "[unknown-msg] hello") {
t.Fatalf("expected delta to be printed before completion, got:\n%s", got)
}
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: " world\n", Background: true})
@ -53,9 +53,9 @@ func TestEventRouterPrintsUnregisteredRun(t *testing.T) {
got := out.String()
for _, want := range []string{
"[node-unknown-event] start run_id=run-bg session=s1 background=true",
"[node-unknown-event] complete run_id=run-bg detail=\"done\"",
"[node-unknown-message] hello world",
"[unknown-evt] start run_id=run-bg session=s1 background=true",
"[unknown-evt] complete run_id=run-bg detail=\"done\"",
"[unknown-msg] hello world",
} {
if !strings.Contains(got, want) {
t.Fatalf("expected output to contain %q, got:\n%s", want, got)
@ -65,28 +65,28 @@ func TestEventRouterPrintsUnregisteredRun(t *testing.T) {
func TestResponseStreamWritesBeforeFinish(t *testing.T) {
var out bytes.Buffer
stream := NewResponseStream(&out, "[node-test-message] ")
stream := NewResponseStream(&out, "[node0-msg] ")
stream.Write("hello")
if got := out.String(); got != "[node-test-message] hello" {
if got := out.String(); got != "[node0-msg] hello" {
t.Fatalf("Write should print immediately, got %q", got)
}
stream.Write(" world")
stream.Finish()
if got := out.String(); got != "[node-test-message] hello world\n" {
if got := out.String(); got != "[node0-msg] hello world\n" {
t.Fatalf("Finish should close the line, got %q", got)
}
}
func TestResponseStreamPrefixesEachMessageLine(t *testing.T) {
var out bytes.Buffer
stream := NewResponseStream(&out, "[node-test-message] ")
stream := NewResponseStream(&out, "[node0-msg] ")
stream.Write("line one\nline two\nline three")
stream.Finish()
want := "[node-test-message] line one\n[node-test-message] line two\n[node-test-message] line three\n"
want := "[node0-msg] line one\n[node0-msg] line two\n[node0-msg] line three\n"
if got := out.String(); got != want {
t.Fatalf("unexpected multiline output:\nwant: %q\n got: %q", want, got)
}
@ -106,8 +106,8 @@ func TestEventRouterPrintsNodeScopedAsyncRun(t *testing.T) {
})
got := out.String()
if !strings.Contains(got, "[node-alias-1-event] start run_id=run-1") {
t.Errorf("expected node-alias-1-event label, got:\n%s", got)
if !strings.Contains(got, "[node0-evt] start run_id=run-1") {
t.Errorf("expected node0 event label, got:\n%s", got)
}
}
@ -126,8 +126,8 @@ func TestEventRouterPrefersRunEventAlias(t *testing.T) {
})
got := out.String()
if !strings.Contains(got, "[node-event-alias-event] start run_id=run-1") {
t.Errorf("expected event-alias label, got:\n%s", got)
if !strings.Contains(got, "[node0-evt] start run_id=run-1") {
t.Errorf("expected node0 event label, got:\n%s", got)
}
}
@ -143,14 +143,16 @@ func TestEventRouterFallsBackToNodeID(t *testing.T) {
})
got := out.String()
if !strings.Contains(got, "[node-some-raw-id-event] start run_id=run-1") {
t.Errorf("expected node-some-raw-id-event label, got:\n%s", got)
if !strings.Contains(got, "[some-raw-id-evt] start run_id=run-1") {
t.Errorf("expected raw id event label, got:\n%s", got)
}
}
func TestEventRouterPrintsNodeLifecycleEvents(t *testing.T) {
var out bytes.Buffer
router := NewEventRouter(&out, nil, nil)
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"})
router := NewEventRouter(&out, reg, nil)
router.HandleNodeEvent(&iop.EdgeNodeEvent{
Type: eventpkg.TypeNodeDisconnected,
@ -164,7 +166,7 @@ func TestEventRouterPrintsNodeLifecycleEvents(t *testing.T) {
})
got := out.String()
if !strings.Contains(got, `[node-alias-1-event] disconnected reason="transport_closed"`) {
if !strings.Contains(got, `[node0-evt] disconnected reason="transport_closed"`) {
t.Errorf("expected disconnected lifecycle output, got:\n%s", got)
}
if !strings.Contains(got, `transport_close_reason="heartbeat_timeout"`) {

View file

@ -86,7 +86,7 @@ func sendNodeCommandView(ctx context.Context, out io.Writer, target *TargetState
// FormatNodeCommandView renders a node command result map in a stable,
// sorted order so output is predictable for tests and humans.
func FormatNodeCommandView(out io.Writer, label string, view edgeservice.NodeCommandView) {
fmt.Fprintf(out, "[node-%s-%s] target=%s session=%s\n", view.NodeLabel, label, view.Target, view.SessionID)
fmt.Fprintf(out, "[%s-%s] target=%s session=%s\n", view.NodeLabel, label, view.Target, view.SessionID)
if len(view.Result) == 0 {
fmt.Fprintf(out, "no %s payload returned\n", label)
return
@ -102,7 +102,7 @@ func FormatNodeCommandView(out io.Writer, label string, view edgeservice.NodeCom
}
func FormatUsageStatus(out io.Writer, nodeAlias, targetName, sessionID string, status *iop.AgentUsageStatus) {
fmt.Fprintf(out, "[node-%s-status] target=%s session=%s\n", nodeAlias, targetName, sessionID)
fmt.Fprintf(out, "[%s-status] target=%s session=%s\n", nodeAlias, targetName, sessionID)
if status == nil {
fmt.Fprintln(out, "no usage status provided")

View file

@ -423,8 +423,5 @@ func normalizeTimeoutSec(timeoutSec int) int {
}
func nodeLabel(entry *edgenode.NodeEntry) string {
if entry.Alias != "" {
return entry.Alias
}
return entry.NodeID
return entry.DisplayLabel()
}

View file

@ -161,11 +161,11 @@ func TestListNodesReturnsSnapshots(t *testing.T) {
for _, s := range snaps {
byID[s.NodeID] = s
}
if got := byID["node-1"]; got.Alias != "alpha" || got.Label != "alpha" {
t.Errorf("node-1 snapshot: %+v, want Label=alpha", got)
if got := byID["node-1"]; got.Alias != "alpha" || got.Label != "node0" {
t.Errorf("node-1 snapshot: %+v, want Label=node0", got)
}
if got := byID["node-2"]; got.Alias != "" || got.Label != "node-2" {
t.Errorf("node-2 snapshot: %+v, want Label=node-2", got)
if got := byID["node-2"]; got.Alias != "" || got.Label != "node1" {
t.Errorf("node-2 snapshot: %+v, want Label=node1", got)
}
}
@ -178,7 +178,7 @@ func TestResolveNodeSnapshotReturnsDTO(t *testing.T) {
if err != nil {
t.Fatalf("ResolveNodeSnapshot: %v", err)
}
if snap.NodeID != "node-1" || snap.Label != "alpha" {
if snap.NodeID != "node-1" || snap.Label != "node0" {
t.Errorf("snapshot: %+v", snap)
}
}

View file

@ -154,9 +154,11 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
}
entry := &edgenode.NodeEntry{
NodeID: rec.ID,
Alias: rec.Alias,
Client: client,
NodeID: rec.ID,
Alias: rec.Alias,
Client: client,
Index: rec.Index,
HasIndex: true,
}
toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) {
if e.NodeId == "" {
@ -168,7 +170,6 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
s.emitNodeEvent(e)
})
client.AddDisconnectListener(func(_ *toki.TcpClient) {
s.registry.Unregister(rec.ID)
transportInfo := client.DisconnectInfo()
fields := []zap.Field{zap.String("node_id", rec.ID)}
fields = append(fields, transportDisconnectFields(transportInfo)...)
@ -185,6 +186,7 @@ func (s *Server) onNodeConnected(client *toki.TcpClient) {
reason,
transportDisconnectMetadata(transportInfo),
))
s.registry.Unregister(rec.ID)
})
s.registry.Register(entry)
s.logger.Info("node registered",

View file

@ -184,14 +184,18 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
Delta: delta,
Timestamp: time.Now(),
})
} else if waitForFilteredMessage {
} else if waitForFilteredMessage && !outputFilter.HasOutput() {
idleTimer.Reset(idleTimeout)
continue
}
completeMessage := "idle-timeout"
if msg := outputFilter.CompletionMessage(); msg != "" {
completeMessage = msg
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
Message: "idle-timeout",
Message: completeMessage,
Usage: &runtime.UsageStats{
InputTokens: len(strings.Fields(prompt)),
OutputTokens: outputTokens,

View file

@ -464,10 +464,61 @@ func TestCLIExecutePersistentClaudeTUIFiltersTerminalChrome(t *testing.T) {
t.Fatalf("execute: %v", err)
}
combined := testutil.CollectDeltas(sink.Events())
if combined != "message:hello\n" {
events := sink.Events()
combined := testutil.CollectDeltas(events)
if combined != "message:hello" {
t.Fatalf("expected only assistant message delta, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" {
t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message)
}
}
func TestCLIExecutePersistentClaudeTUIStreamsLineBeforeIdle(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf 'sud fragment\r\n\342\234\242 Dilly-dallying\342\200\246\r\n* Dilly-dallying\342\200\246\r\n'; sleep 0.05; printf '\342\227\217 sudo runs commands\r\n\342\235\257 '; sleep 0.15; printf '\r\033[2A\033[2K\342\227\217 sudo runs commands with elevated privileges.\r\n\342\235\257 '; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 300,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = c.Stop(ctx) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-growing",
Target: "claude-tui",
Input: map[string]any{"prompt": "explain sudo"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
combined := testutil.CollectDeltas(events)
if combined != "sudo runs commands with elevated privileges." {
t.Fatalf("expected full growing message, got %q", combined)
}
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" {
t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message)
}
}
func TestCLIExecutePersistentClaudeTUIDoesNotCompleteOnStatusOnlyIdle(t *testing.T) {

View file

@ -13,6 +13,8 @@ import (
type persistentOutputFilter interface {
Filter(chunk string) string
Flush() string
HasOutput() bool
CompletionMessage() string
}
type passthroughOutputFilter struct{}
@ -25,6 +27,14 @@ func (passthroughOutputFilter) Flush() string {
return ""
}
func (passthroughOutputFilter) HasOutput() bool {
return false
}
func (passthroughOutputFilter) CompletionMessage() string {
return ""
}
func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string) persistentOutputFilter {
if profile.Terminal && isClaudeTerminalProfile(target, profile) {
return newClaudeTUIOutputFilter(prompt)
@ -40,8 +50,15 @@ func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool
}
type claudeTUIOutputFilter struct {
raw strings.Builder
prompt string
raw strings.Builder
prompt string
emitted string
seenAssistant bool
}
type claudeMessageSnapshot struct {
text string
closed bool
}
func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter {
@ -50,25 +67,106 @@ func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter {
func (f *claudeTUIOutputFilter) Filter(chunk string) string {
appendBounded(&f.raw, chunk, 256*1024)
return ""
message := f.currentMessage()
return f.emit(message.text, message.closed, false)
}
func (f *claudeTUIOutputFilter) Flush() string {
screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols)
message := latestClaudeAssistantMessage(screen)
if message == "" {
message = fallbackClaudeVisibleMessage(screen, f.prompt)
}
if message == "" {
message = fallbackClaudeVisibleMessage(cleanClaudeTerminalOutput(f.raw.String()), f.prompt)
}
if message == "" {
return ""
}
return message + "\n"
message := f.currentMessage()
return f.emit(message.text, true, true)
}
func latestClaudeAssistantMessage(screen string) string {
func (f *claudeTUIOutputFilter) HasOutput() bool {
return f.emitted != "" || f.currentMessage().text != ""
}
func (f *claudeTUIOutputFilter) CompletionMessage() string {
if f.emitted == "" {
return ""
}
if claudeScreenHasInputPrompt(status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols)) {
return "prompt-ready"
}
return ""
}
func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot {
screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols)
clean := cleanClaudeTerminalOutput(f.raw.String())
message, closed := latestClaudeAssistantMessage(screen)
if cleanMessage, cleanClosed := latestClaudeAssistantMessageFromCleanOutput(clean); cleanMessage != "" {
if message == "" {
f.seenAssistant = true
return claudeMessageSnapshot{text: cleanMessage, closed: cleanClosed}
}
if cleanMessage == message {
closed = closed || cleanClosed
}
}
if message != "" {
f.seenAssistant = true
}
if !f.seenAssistant {
return claudeMessageSnapshot{}
}
if message == "" {
message = fallbackClaudeVisibleMessage(screen, f.prompt)
if message != "" {
closed = claudeScreenHasInputPrompt(screen) || strings.HasSuffix(clean, "\n")
}
}
if message == "" {
message = fallbackClaudeVisibleMessage(clean, f.prompt)
if message != "" {
closed = strings.HasSuffix(clean, "\n")
}
}
return claudeMessageSnapshot{text: message, closed: closed}
}
func (f *claudeTUIOutputFilter) emit(message string, closed bool, flush bool) string {
message = strings.TrimRight(message, "\n")
if !flush {
message = readyLinePrefix(message, closed)
}
if message == "" || message == f.emitted {
return ""
}
if f.emitted == "" {
f.emitted = message
return message
}
if strings.HasPrefix(message, f.emitted) {
delta := strings.TrimPrefix(message, f.emitted)
f.emitted = message
return delta
}
f.emitted = message
return "\n" + message
}
func readyLinePrefix(message string, closed bool) string {
if closed {
return message
}
end := strings.LastIndex(message, "\n")
if end < 0 {
return ""
}
return message[:end+1]
}
func claudeScreenHasInputPrompt(screen string) bool {
for _, line := range strings.Split(screen, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "\u276f") {
return true
}
}
return false
}
func latestClaudeAssistantMessage(screen string) (string, bool) {
lines := strings.Split(screen, "\n")
start := -1
for i, line := range lines {
@ -77,10 +175,11 @@ func latestClaudeAssistantMessage(screen string) string {
}
}
if start < 0 {
return ""
return "", false
}
out := make([]string, 0, len(lines)-start)
closed := false
if text, ok := claudeAssistantLine(lines[start]); ok {
appendClaudeMessageLine(&out, text)
}
@ -93,11 +192,51 @@ func latestClaudeAssistantMessage(screen string) string {
continue
}
if isClaudeTUIBoundaryLine(line) {
closed = true
break
}
appendClaudeMessageLine(&out, line)
}
return strings.Join(trimTrailingEmptyLines(out), "\n")
return strings.Join(trimTrailingEmptyLines(out), "\n"), closed
}
func latestClaudeAssistantMessageFromCleanOutput(text string) (string, bool) {
lines := strings.SplitAfter(text, "\n")
start := -1
for i, line := range lines {
if _, ok := claudeAssistantLine(strings.TrimRight(line, "\r\n")); ok {
start = i
}
}
if start < 0 {
return "", false
}
out := make([]string, 0, len(lines)-start)
closed := false
if text, ok := claudeAssistantLine(strings.TrimRight(lines[start], "\r\n")); ok {
appendClaudeMessageLine(&out, text)
closed = strings.HasSuffix(lines[start], "\n")
}
for _, raw := range lines[start+1:] {
line := strings.TrimSpace(strings.TrimRight(raw, "\r\n"))
if line == "" {
if len(out) > 0 {
out = append(out, "")
if strings.HasSuffix(raw, "\n") {
closed = true
}
}
continue
}
if isClaudeTUIBoundaryLine(line) {
closed = true
break
}
appendClaudeMessageLine(&out, line)
closed = strings.HasSuffix(raw, "\n")
}
return strings.Join(trimTrailingEmptyLines(out), "\n"), closed
}
func appendClaudeMessageLine(out *[]string, line string) {
@ -132,7 +271,7 @@ func isClaudeTUIBoundaryLine(line string) bool {
return true
case strings.HasPrefix(line, "\u2570") || strings.HasPrefix(line, "\u256d"):
return true
case strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u00b7"):
case strings.HasPrefix(line, "\u2722") || strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u273d") || strings.HasPrefix(line, "\u00b7"):
return true
case strings.HasPrefix(line, "\u23bf"):
return true
@ -235,6 +374,8 @@ func isClaudeTransientStatusLine(line string) bool {
for _, word := range []string{
"cogitating",
"crunched",
"dallying",
"dilly",
"pondering",
"processing",
"thinking",

View file

@ -10,13 +10,12 @@ import (
func TestClaudeTUIOutputFilterExtractsAssistantMessageOnly(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
if got := filter.Filter("\x1b[2C\r\x1b[7A\u25cf\x1b[1CIOP_MESSAGE_ONLY\r\x1b[2B\u00b7 Cogitating... (2s · 1 tokens)\x1b[K\r\n\u276f "); got != "" {
t.Fatalf("Filter() = %q, want deferred output", got)
got := filter.Filter("\x1b[2C\r\x1b[7A\u25cf\x1b[1CIOP_MESSAGE_ONLY.\r\x1b[2B\u00b7 Cogitating... (2s · 1 tokens)\x1b[K\r\n\u276f ")
if got != "IOP_MESSAGE_ONLY." {
t.Fatalf("Filter() = %q, want %q", got, "IOP_MESSAGE_ONLY.")
}
got := filter.Flush()
if got != "IOP_MESSAGE_ONLY\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_MESSAGE_ONLY\n")
if got := filter.Flush(); got != "" {
t.Fatalf("Flush() should not duplicate already-emitted output, got %q", got)
}
if strings.Contains(got, "\x1b") || strings.Contains(got, "Cogitating") || strings.Contains(got, "\u276f") {
t.Fatalf("filtered message still contains terminal chrome: %q", got)
@ -26,25 +25,25 @@ func TestClaudeTUIOutputFilterExtractsAssistantMessageOnly(t *testing.T) {
func TestClaudeTUIOutputFilterSuppressesRepaintedMessage(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
if got := filter.Filter("\u25cf IOP_MESSAGE_ONCE\r\n"); got != "" {
t.Fatalf("Filter() = %q, want deferred output", got)
if got := filter.Filter("\u25cf IOP_MESSAGE_ONCE.\r\n"); got != "IOP_MESSAGE_ONCE." {
t.Fatalf("Filter() = %q, want first message", got)
}
if got := filter.Filter("\r\x1b[4A\u25cf IOP_MESSAGE_ONCE\r\n"); got != "" {
if got := filter.Filter("\r\x1b[4A\u25cf IOP_MESSAGE_ONCE.\r\n"); got != "" {
t.Fatalf("repainted Filter() = %q, want deferred output", got)
}
if got := filter.Flush(); got != "IOP_MESSAGE_ONCE\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_MESSAGE_ONCE\n")
if got := filter.Flush(); got != "" {
t.Fatalf("Flush() should not duplicate already-emitted output, got %q", got)
}
}
func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
filter.Filter("\u25cf IOP_FIRST_MESSAGE\r\n\u2500\r\n")
filter.Filter("\u25cf IOP_SECOND_MESSAGE\r\n\u276f ")
filter.Filter("\u25cf IOP_FIRST_MESSAGE.\r\n\u2500\r\n")
got := filter.Filter("\u25cf IOP_SECOND_MESSAGE.\r\n\u276f ")
if got := filter.Flush(); got != "IOP_SECOND_MESSAGE\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_SECOND_MESSAGE\n")
if got != "\nIOP_SECOND_MESSAGE." {
t.Fatalf("Filter() = %q, want latest message replacement", got)
}
}
@ -58,38 +57,106 @@ func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) {
}
}
func TestClaudeTUIOutputFilterFallsBackWhenAssistantMarkerIsAbsent(t *testing.T) {
func TestClaudeTUIOutputFilterSuppressesStartupNoiseBeforeAssistantMarker(t *testing.T) {
filter := newClaudeTUIOutputFilter("hello")
filter.Filter("\r\x1b[2Khello\r\n")
filter.Filter("\r\x1b[2KIOP_MARKERLESS_REPLY\r\n")
filter.Filter("\u276f ")
for _, chunk := range []string{
"\r\x1b[2Ksud\uc5d0 \ub300\ud574\r\n",
"\u2722 Dilly-dallying\u2026\r\n",
"* Dilly-dallying\u2026\r\n",
"\r\x1b[2K\uc124\uba85\ud574\ubd10\r\n",
} {
if got := filter.Filter(chunk); got != "" {
t.Fatalf("Filter(%q) = %q, want suppressed startup noise", chunk, got)
}
}
if got := filter.Flush(); got != "IOP_MARKERLESS_REPLY\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_MARKERLESS_REPLY\n")
if got := filter.Flush(); got != "" {
t.Fatalf("Flush() = %q, want no startup noise", got)
}
}
func TestClaudeTUIOutputFilterKeepsMultilineAssistantMessage(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
filter.Filter("\u25cf line one\r\nline two\r\n\r\nline four\r\n\r\n\u273b Crunched for 4s\r\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n\u276f ")
got := filter.Filter("\u25cf line one\r\nline two\r\n\r\nline four.\r\n\r\n\u273b Crunched for 4s\r\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n\u276f ")
want := "line one\nline two\n\nline four\n"
if got := filter.Flush(); got != want {
t.Fatalf("Flush() = %q, want %q", got, want)
want := "line one\nline two\n\nline four."
if got != want {
t.Fatalf("Filter() = %q, want %q", got, want)
}
}
func TestClaudeTUIOutputFilterDropsFooterWithoutAssistantMarker(t *testing.T) {
func TestClaudeTUIOutputFilterDoesNotFallbackBeforeAssistantMarker(t *testing.T) {
filter := newClaudeTUIOutputFilter("hello")
filter.Filter("hello\r\n")
filter.Filter("real reply\r\n")
if got := filter.Filter("real reply.\r\n"); got != "" {
t.Fatalf("Filter() = %q, want markerless output suppressed", got)
}
filter.Filter("\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n")
if got := filter.Flush(); got != "real reply\n" {
t.Fatalf("Flush() = %q, want %q", got, "real reply\n")
if got := filter.Flush(); got != "" {
t.Fatalf("Flush() = %q, want markerless output suppressed", got)
}
}
func TestClaudeTUIOutputFilterKeepsAssistantAfterStartupNoise(t *testing.T) {
filter := newClaudeTUIOutputFilter("sudo\uc5d0 \ub300\ud574 \uc124\uba85\ud574\ubd10")
for _, chunk := range []string{
"\r\x1b[2Ksud\uc5d0 \ub300\ud574\r\n",
"\u2722 Dilly-dallying\u2026\r\n",
"* Dilly-dallying\u2026\r\n",
"\r\x1b[2K\uc124\uba85\ud574\ubd10\r\n",
} {
if got := filter.Filter(chunk); got != "" {
t.Fatalf("startup Filter(%q) = %q, want suppressed", chunk, got)
}
}
got := filter.Filter("\u25cf sudo is a command for elevated privileges\r\n\u2722 Dilly-dallying\u2026\r\n\u276f ")
if got != "sudo is a command for elevated privileges" {
t.Fatalf("Filter() = %q, want assistant message only", got)
}
}
func TestClaudeTUIOutputFilterWaitsForLineBoundary(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
if got := filter.Filter("\u25cf sudo runs commands"); got != "" {
t.Fatalf("first Filter() = %q", got)
}
got := filter.Filter("\r\n\u276f ")
if got != "sudo runs commands" {
t.Fatalf("second Filter() = %q, want completed line", got)
}
}
func TestClaudeTUIOutputFilterEmitsSuffixAfterLineRepaint(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
if got := filter.Filter("\u25cf First sentence.\r\n\u276f "); got != "First sentence." {
t.Fatalf("first Filter() = %q", got)
}
got := filter.Filter("\r\x1b[2A\x1b[2K\u25cf First sentence. Second sentence.\r\n\u276f ")
if got != " Second sentence." {
t.Fatalf("second Filter() = %q, want suffix only", got)
}
}
func TestClaudeTUIOutputFilterEmitsCompleteLineBeforePartialNextLine(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
got := filter.Filter("\u25cf line one\r\nline two")
if got != "line one\n" {
t.Fatalf("Filter() = %q, want first completed line", got)
}
got = filter.Filter("\r\n\u276f ")
if got != "line two" {
t.Fatalf("second Filter() = %q, want completed second line", got)
}
}
@ -109,10 +176,10 @@ func TestClaudeTUIOutputFilterKeepsAssistantAfterTransientStatus(t *testing.T) {
filter.Filter("\uc548\ub155?\r\n")
filter.Filter("3\r\n\r\nThundering\u20266\r\n")
filter.Filter("\u25cf \uc548\ub155\ud558\uc138\uc694.\r\n")
got := filter.Filter("\u25cf \uc548\ub155\ud558\uc138\uc694.\r\n")
if got := filter.Flush(); got != "\uc548\ub155\ud558\uc138\uc694.\n" {
t.Fatalf("Flush() = %q, want %q", got, "\uc548\ub155\ud558\uc138\uc694.\n")
if got != "\uc548\ub155\ud558\uc138\uc694." {
t.Fatalf("Filter() = %q, want %q", got, "\uc548\ub155\ud558\uc138\uc694.")
}
}

View file

@ -4,6 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "[e2e] NOTE: auxiliary smoke only; completion requires bin/edge.sh + bin/node.sh user-flow verification."
if command -v shellcheck >/dev/null 2>&1; then
echo "[e2e] running shellcheck..."
shellcheck "$0" "$REPO_ROOT/bin/edge.sh" "$REPO_ROOT/bin/node.sh"
@ -31,10 +33,13 @@ IS_PERSISTENT=0
HAS_STATUS=0
FIRST_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_alpha"
FIRST_EXPECTED="SMOKE_ALPHA"
FIRST_TAIL_EXPECTED="IOP_SMOKE_ALPHA_TAIL"
SECOND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_beta"
SECOND_EXPECTED="SMOKE_BETA"
SECOND_TAIL_EXPECTED="IOP_SMOKE_BETA_TAIL"
BACKGROUND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_bg"
BACKGROUND_EXPECTED="SMOKE_BG"
BACKGROUND_TAIL_EXPECTED="IOP_SMOKE_BG_TAIL"
if [ "$PROFILE" = "mock" ]; then
echo "[e2e] preparing honest mock smoke test (using scripted cli adapter)..."
@ -45,9 +50,9 @@ if [ "$PROFILE" = "mock" ]; then
#!/usr/bin/env sh
while IFS= read -r line; do
case "$line" in
*iop_smoke_alpha*) printf 'IOP_SMOKE_ALPHA\n' ;;
*iop_smoke_beta*) printf 'IOP_SMOKE_BETA\n' ;;
*iop_smoke_bg*) printf 'IOP_SMOKE_BG\n' ;;
*iop_smoke_alpha*) printf 'IOP_SMOKE_ALPHA\nIOP_SMOKE_ALPHA_TAIL\n' ;;
*iop_smoke_beta*) printf 'IOP_SMOKE_BETA\nIOP_SMOKE_BETA_TAIL\n' ;;
*iop_smoke_bg*) printf 'IOP_SMOKE_BG\nIOP_SMOKE_BG_TAIL\n' ;;
*) printf 'IOP_SMOKE_UNKNOWN\n' ;;
esac
done
@ -197,7 +202,7 @@ wait_for_node_registration() {
deadline=$((SECONDS + REGISTER_TIMEOUT))
echo "[e2e] waiting for node registration (timeout: ${REGISTER_TIMEOUT}s)"
while ! grep -q '\[node-test-node-event\] connected reason="registered"' "$EDGE_OUT"; do
while ! grep -q '\[node0-evt\] connected reason="registered"' "$EDGE_OUT"; do
ensure_smoke_processes_running "node registration"
if (( SECONDS >= deadline )); then
echo "[e2e] node registration timed out"
@ -280,31 +285,31 @@ wait_for_node_registration
send_cmd "/nodes"
wait_for_edge_text_since "test-node (test-node)" "$LAST_CMD_START_LINE" "/nodes output"
send_cmd "/capabilities"
wait_for_edge_text_since "[node-test-node-capabilities]" "$LAST_CMD_START_LINE" "/capabilities output"
wait_for_edge_text_since "[node0-capabilities]" "$LAST_CMD_START_LINE" "/capabilities output"
wait_for_edge_text_since "targets = ${TARGET}" "$LAST_CMD_START_LINE" "/capabilities targets"
send_cmd "/transport"
wait_for_edge_text_since "[node-test-node-transport]" "$LAST_CMD_START_LINE" "/transport output"
wait_for_edge_text_since "[node0-transport]" "$LAST_CMD_START_LINE" "/transport output"
wait_for_edge_text_since "connected = true" "$LAST_CMD_START_LINE" "/transport connected status"
send_cmd "$FIRST_PROMPT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 1 start" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 1 start" "$RUN_TIMEOUT"
wait_for_edge_text_since "$FIRST_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 1 response" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 1 completion" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 1 completion" "$RUN_TIMEOUT"
send_cmd "$SECOND_PROMPT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 2 start" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 2 start" "$RUN_TIMEOUT"
wait_for_edge_text_since "$SECOND_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 2 response" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 2 completion" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 2 completion" "$RUN_TIMEOUT"
send_cmd "/session session2"
wait_for_edge_pattern_since "session .*session2" "$LAST_CMD_START_LINE" "/session acknowledgement"
send_cmd "/background on"
wait_for_edge_pattern_since "background .*on" "$LAST_CMD_START_LINE" "/background on acknowledgement"
send_cmd "$BACKGROUND_PROMPT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=.*session=session2.*background=true" "$LAST_CMD_START_LINE" "background run start" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] start run_id=.*session=session2.*background=true" "$LAST_CMD_START_LINE" "background run start" "$RUN_TIMEOUT"
wait_for_edge_text_since "$BACKGROUND_EXPECTED" "$LAST_CMD_START_LINE" "background run response" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "background run completion" "$RUN_TIMEOUT"
wait_for_edge_pattern_since "\\[node0-evt\\] complete run_id=" "$LAST_CMD_START_LINE" "background run completion" "$RUN_TIMEOUT"
send_cmd "/background off"
wait_for_edge_pattern_since "background .*off" "$LAST_CMD_START_LINE" "/background off acknowledgement"
send_cmd "/sessions"
wait_for_edge_text_since "[node-test-node-sessions]" "$LAST_CMD_START_LINE" "/sessions output"
wait_for_edge_text_since "[node0-sessions]" "$LAST_CMD_START_LINE" "/sessions output"
if [ "$IS_PERSISTENT" -eq 1 ]; then
send_cmd "/terminate-session"
@ -313,7 +318,7 @@ fi
if [ "$HAS_STATUS" -eq 1 ]; then
send_cmd "/status"
wait_for_edge_text_since "[node-test-node-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT"
wait_for_edge_text_since "[node0-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT"
fi
IDLE_FAIL=0
@ -378,30 +383,95 @@ check_fail_markers() {
local file="$1"
# Strict failure detection for system/console errors
# We catch panics, unexpected exits, startup failures, and run-level error events.
if grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[node-.*-event\] error" "$file" >/dev/null; then
if grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[[^]]+-evt\] error" "$file" >/dev/null; then
echo "[e2e] FAIL: detected error marker in $file"
grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[node-.*-event\] error" "$file"
grep -i -E "panic:|unexpected|startup failed|cancel error|node reported error|does not support|adapter .* not found|no session|error run_id=|\[node-event\] error|\[[^]]+-evt\] error" "$file"
FAIL=1
fi
}
check_node_messages_relayed_to_edge() {
local node_messages="$TMP_DIR/node_messages"
local edge_messages="$TMP_DIR/edge_messages"
local node_counts="$TMP_DIR/node_message_counts"
local count
local payload
local edge_count
# Compare the messages the node printed locally with the messages rendered
# by the edge console. A complete event is not enough if some node output
# never reached the edge.
awk '
/^\[node-message\] / {
in_message = 1
sub(/^\[node-message\] /, "")
if ($0 != "<empty>") {
print
}
next
}
in_message && /^\[/ {
in_message = 0
}
in_message && /^\{/ {
in_message = 0
}
in_message && $0 != "" {
print
}
' "$NODE_OUT" > "$node_messages"
awk '
/^\[[^]]+-msg\] / {
sub(/^\[[^]]+-msg\] /, "")
if ($0 != "<empty>") {
print
}
}
' "$EDGE_OUT" > "$edge_messages"
if [ ! -s "$node_messages" ]; then
echo "[e2e] FAIL: no node-generated message payloads found in node output"
FAIL=1
return
fi
sort "$node_messages" | uniq -c > "$node_counts"
while read -r count payload; do
if [ -z "$payload" ]; then
continue
fi
edge_count=$(grep -Fxc -- "$payload" "$edge_messages" || true)
if [ "$edge_count" -lt "$count" ]; then
echo "[e2e] FAIL: node message payload was not fully relayed to edge"
echo "[e2e] missing-or-short payload: $payload"
echo "[e2e] node_count=$count edge_count=$edge_count"
FAIL=1
fi
done < "$node_counts"
}
check_grep "test-node" "$EDGE_OUT" "node registration not found"
check_grep "start run_id=" "$EDGE_OUT" "run start not found"
check_grep "complete run_id=" "$EDGE_OUT" "run completion not found"
if [ "$(grep -c "\\[node-test-node-event\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then
if [ "$(grep -c "\\[node0-evt\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then
echo "[e2e] FAIL: expected at least 3 completed runs (foreground x2 + background x1)"
FAIL=1
fi
check_grep "$FIRST_EXPECTED" "$EDGE_OUT" "foreground run 1 response not found"
check_grep "$FIRST_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 1 tail response not found"
check_grep "$SECOND_EXPECTED" "$EDGE_OUT" "foreground run 2 response not found"
check_grep "$SECOND_TAIL_EXPECTED" "$EDGE_OUT" "foreground run 2 tail response not found"
check_grep "$BACKGROUND_EXPECTED" "$EDGE_OUT" "background run response not found"
check_no_grep "\\[node-test-node-message\\] <empty>" "$EDGE_OUT" "empty node message found"
check_grep "$BACKGROUND_TAIL_EXPECTED" "$EDGE_OUT" "background run tail response not found"
check_no_grep "\\[node0-msg\\] <empty>" "$EDGE_OUT" "empty node message found"
check_node_messages_relayed_to_edge
check_grep "\\[node-test-node-capabilities\\]" "$EDGE_OUT" "/capabilities output not found"
check_grep "\\[node0-capabilities\\]" "$EDGE_OUT" "/capabilities output not found"
check_grep "targets = ${TARGET}" "$EDGE_OUT" "/capabilities targets not found"
check_grep "\\[node-test-node-transport\\]" "$EDGE_OUT" "/transport output not found"
check_grep "\\[node0-transport\\]" "$EDGE_OUT" "/transport output not found"
check_grep "connected = true" "$EDGE_OUT" "/transport connected status not found"
check_grep "\\[node-test-node-sessions\\]" "$EDGE_OUT" "/sessions output not found"
check_grep "\\[node0-sessions\\]" "$EDGE_OUT" "/sessions output not found"
if [ "$IS_PERSISTENT" -eq 1 ]; then
check_grep "persistent:${TARGET}/session2" "$EDGE_OUT" "/sessions entry for session2 not found"
@ -448,9 +518,10 @@ if [ "$PROFILE" != "mock" ]; then
fi
if [ $FAIL -eq 1 ]; then
echo "[e2e] Smoke test FAILED."
echo "[e2e] Auxiliary smoke test FAILED."
exit 1
fi
echo "[e2e] Smoke test PASSED."
echo "[e2e] Auxiliary smoke test PASSED."
echo "[e2e] Completion still requires bin/edge.sh + bin/node.sh user-flow verification."
exit 0