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:
parent
42e0810a26
commit
92042bb2a3
20 changed files with 756 additions and 246 deletions
1
Makefile
1
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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를 사용한다.
|
||||
- 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다.
|
||||
|
|
|
|||
|
|
@ -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`를 생성하고 이 표에 등록한다.
|
||||
|
|
|
|||
|
|
@ -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`을 임시값으로 오염시키지 않는다.
|
||||
- 필수 검증을 실행하지 못했는데 조용히 생략하지 않는다.
|
||||
|
|
|
|||
|
|
@ -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 명령
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue